"""CLI entrypoint for gnos."""

from __future__ import annotations

import argparse
import sys
from pathlib import Path
from importlib.metadata import version as pkg_version, PackageNotFoundError

from ..core.add import add_path, add_collection, add_bytes, AddError
from ..core.attest import (
    attest_manifest_bytes_shallow,
    attest_any_manifest_bytes,
    attest_file_to_doc_id,
    attest_doc_id,
    AttestationError,
)
from ..core.materialize import (
    materialize_doc_id,
    materialize_collection,
    MaterializeError,
)
from ..core.derive import derive_from_manifest, DeriveError
from ..core.store import ObjectStore
from ..index.local import LocalIndex
from ..chunking.registry import get_method, list_methods
from ..manifest.codec import load_collection_bytes, load_manifest_object, load_manifest_bytes
import json
import binascii


def _store_from_cwd() -> ObjectStore:
    return ObjectStore(Path(".gnos"))


def _index_from_cwd() -> LocalIndex:
    return LocalIndex(Path(".gnos") / "index.json")


def cmd_init(_args: argparse.Namespace) -> int:
    store = _store_from_cwd()
    store.init()
    print("initialized .gnos")
    return 0


def _print_add_result(result, verbose: bool = False) -> None:
    print("added document:")
    print(f"  path:          {result.path}")
    print(f"  doc_id:        {result.doc_id}")
    print(f"  format:        {result.format}")
    print(f"  manifest:      {result.manifest_hash}")
    print(f"  chunks stored: {result.chunk_count}")
    if verbose:
        method = get_method(result.chunking.get("method"))
        print(f"  chunking:      {method.name}")


def _parse_chunking_params(text: str) -> dict:
    """Parse comma-separated key=value pairs into a dict.

    Example:
      chunk_size=1048576,max_payload_bytes=1024,parser=commonmark-0.30

    Notes:
    - Keys must be non-empty.
    - Values are parsed as bool/int/float when obvious; otherwise kept as str.
    - Commas in values are not supported.
    """
    if text is None:
        return {}
    text = text.strip()
    if text == "":
        return {}

    out: dict = {}
    for part in text.split(","):
        part = part.strip()
        if part == "":
            continue
        if "=" not in part:
            raise ValueError(f"invalid chunking param (expected key=value): {part!r}")
        key, value = part.split("=", 1)
        key = key.strip()
        value = value.strip()
        if key == "":
            raise ValueError("invalid chunking param: empty key")
        lower = value.lower()
        if lower in {"true", "false"}:
            parsed = lower == "true"
        else:
            parsed = value
            try:
                parsed = int(value)
            except ValueError:
                try:
                    parsed = float(value)
                except ValueError:
                    parsed = value
        out[key] = parsed
    return out


def cmd_add(args: argparse.Namespace) -> int:
    store = _store_from_cwd()
    index = _index_from_cwd()
    try:
        chunking_params = _parse_chunking_params(args.chunking_params)
        if args.path == "-":
            data = sys.stdin.buffer.read()
            fmt = None if args.format == "auto" else args.format
            result = add_bytes(
                data,
                store,
                format=fmt,
                chunking=args.chunking,
                chunking_params=chunking_params,
                index=index,
                dry_run=args.dry_run,
                label="<stdin>",
            )
            if args.verbose:
                _print_add_result(result, verbose=True)
            return 0
        if args.collection:
            result = add_collection(
                Path(args.path),
                store,
                format=args.format,
                chunking=args.chunking,
                chunking_params=chunking_params,
                recursive=not args.no_recursive,
                index=index,
                dry_run=args.dry_run,
            )
            if args.verbose:
                for doc in result.documents:
                    _print_add_result(doc, verbose=True)
                print("added collection:")
                print(f"  collection_id: {result.collection_id}")
            return 0

        batch = add_path(
            Path(args.path),
            store,
            format=args.format,
            chunking=args.chunking,
            chunking_params=chunking_params,
            recursive=not args.no_recursive,
            index=index,
            dry_run=args.dry_run,
        )
    except AddError as e:
        print(f"error: {e}", file=sys.stderr)
        return 1
    except ValueError as e:
        print(f"error: {e}", file=sys.stderr)
        return 1

    if args.verbose:
        for result in batch.results:
            _print_add_result(result, verbose=True)
    return 0


def cmd_attest_manifest(args: argparse.Namespace) -> int:
    if args.shallow and args.doc_id is not None:
        print("error: --doc-id is not compatible with --shallow", file=sys.stderr)
        return 1
    store = _store_from_cwd()
    index = _index_from_cwd()
    manifest_bytes = store.get(args.manifest_hash)
    if manifest_bytes is None:
        print("error: manifest not found in local store", file=sys.stderr)
        return 1
    try:
        if args.shallow:
            attest_manifest_bytes_shallow(
                manifest_bytes,
                expected_manifest_hash=args.manifest_hash,
            )
        else:
            attest_any_manifest_bytes(
                manifest_bytes,
                store,
                index,
                expected_doc_id=args.doc_id,
                expected_manifest_hash=args.manifest_hash,
            )
    except AttestationError as e:
        print(f"attest failed: {e}", file=sys.stderr)
        return 2
    print("attest ok")
    return 0


def cmd_attest_file(args: argparse.Namespace) -> int:
    try:
        data = Path(args.path).read_bytes()
    except OSError as e:
        print(f"error: cannot read file: {e}", file=sys.stderr)
        return 1
    try:
        attest_file_to_doc_id(data, args.doc_id)
    except AttestationError as e:
        print(f"attest failed: {e}", file=sys.stderr)
        return 2
    print("attest ok")
    return 0


def cmd_attest_doc(args: argparse.Namespace) -> int:
    store = _store_from_cwd()
    index = _index_from_cwd()
    try:
        attest_doc_id(args.doc_id, store, index)
    except AttestationError as e:
        print(f"attest failed: {e}", file=sys.stderr)
        return 2
    print("attest ok")
    return 0




def cmd_materialize_doc(args: argparse.Namespace) -> int:
    store = _store_from_cwd()
    index = _index_from_cwd()
    try:
        result = materialize_doc_id(args.doc_id, store, index, Path(args.output))
    except MaterializeError as e:
        print(f"materialize failed: {e}", file=sys.stderr)
        return 2
    if args.output != "-":
        print("materialized:")
        print(f"  doc_id: {result.doc_id}")
        print(f"  path:   {result.output_path}")
    return 0


def cmd_materialize_collection(args: argparse.Namespace) -> int:
    store = _store_from_cwd()
    index = _index_from_cwd()
    try:
        result = materialize_collection(args.collection_id, store, index, Path(args.dir))
    except MaterializeError as e:
        print(f"materialize failed: {e}", file=sys.stderr)
        return 2
    print("materialized:")
    print(f"  collection_id: {result.collection_id}")
    print(f"  dir:           {result.output_dir}")
    return 0


def cmd_derive(args: argparse.Namespace) -> int:
    store = _store_from_cwd()
    try:
        chunking_params = _parse_chunking_params(args.chunking_params)
        result = derive_from_manifest(
            args.manifest_hash,
            store,
            chunking=args.chunking,
            chunking_params=chunking_params,
            dry_run=args.dry_run,
        )
    except DeriveError as e:
        print(f"error: {e}", file=sys.stderr)
        return 1
    except ValueError as e:
        print(f"error: {e}", file=sys.stderr)
        return 1
    if args.verbose:
        print("derived manifest:")
        print(f"  doc_id:         {result.doc_id}")
        print(f"  source_manifest:{result.source_manifest}")
        print(f"  derived:        {result.derived_manifest}")
        print(f"  chunking:       {result.chunking.get('method')}")
        print(f"  chunks stored:  {result.chunk_count}")
    return 0


def _count_complete_manifests(manifests: dict, store: ObjectStore) -> int:
    complete = 0
    for m in manifests.values():
        chunks = m.get("chunks", [])
        if all(store.has(ch.get("hash")) for ch in chunks):
            complete += 1
    return complete


def _doc_is_complete(doc_id: str, index_data: dict, store: ObjectStore) -> bool:
    documents = index_data.get("documents", {})
    manifests = index_data.get("manifests", {})
    doc = documents.get(doc_id)
    if doc is None:
        return False
    manifest_hashes = doc.get("manifests", [])
    for h in manifest_hashes:
        m = manifests.get(h)
        if m is None:
            continue
        chunks = m.get("chunks", [])
        if all(store.has(ch.get("hash")) for ch in chunks):
            return True
    return False


def _list_collections(store: ObjectStore) -> list[tuple[str, int]]:
    collections: list[tuple[str, int]] = []
    for obj in store.objects_dir().rglob("*"):
        if not obj.is_file():
            continue
        try:
            blob = obj.read_bytes()
        except OSError:
            continue
        try:
            collection = load_collection_bytes(blob)
        except Exception:
            continue
        collections.append((obj.name, len(collection.documents)))
    return collections


def _count_manifest_types(store: ObjectStore) -> tuple[int, int, int]:
    doc_count = 0
    collection_count = 0
    derived_count = 0
    for obj in store.objects_dir().rglob("*"):
        if not obj.is_file():
            continue
        try:
            blob = obj.read_bytes()
            manifest = load_manifest_object(blob)
        except (OSError, ValueError):
            continue
        mtype = manifest.get("type")
        if mtype == "doc-manifest":
            doc_count += 1
        elif mtype == "collection-manifest":
            collection_count += 1
        elif mtype == "derived-manifest":
            derived_count += 1
    return doc_count, collection_count, derived_count


def cmd_status(args: argparse.Namespace) -> int:
    store = _store_from_cwd()
    if not store.objects_dir().exists():
        print("store not initialized")
        return 1

    objects = list(store.objects_dir().rglob("*"))
    files = [p for p in objects if p.is_file()]
    total_size = sum(p.stat().st_size for p in files)

    index = _index_from_cwd()
    data = index.load()
    manifests = data.get("manifests", {})
    documents = data.get("documents", {})
    collections = _list_collections(store)

    if args.doc_id:
        doc = documents.get(args.doc_id)
        if doc is None:
            print("document not found in local index")
            return 1
        manifest_hashes = doc.get("manifests", [])
        doc_manifests = {h: manifests[h] for h in manifest_hashes if h in manifests}
        complete = _count_complete_manifests(doc_manifests, store)
        print("document status:")
        print(f"  doc_id:    {args.doc_id}")
        print(f"  manifests: {len(doc_manifests)}")
        print(f"  complete:  {complete}")
        return 0

    complete_manifests = _count_complete_manifests(manifests, store)
    doc_manifests, collection_manifests, derived_manifests = _count_manifest_types(store)
    print("store status:")
    print(f"  objects:   {len(files)}")
    print(f"  size:      {total_size} bytes")
    print(f"  doc-manifests: {doc_manifests}")
    print(f"  collection-manifests: {collection_manifests}")
    print(f"  derived-manifests: {derived_manifests}")
    print("index status:")
    print(f"  documents: {len(documents)}")
    print(f"  complete:  {complete_manifests}")
    if args.collections:
        print("collections:")
        for collection_id, count in collections:
            complete_docs = 0
            collection = load_collection_bytes(store.get(collection_id) or b"")
            for entry in collection.documents:
                if _doc_is_complete(entry.doc_id, data, store):
                    complete_docs += 1
            print(f"  {collection_id}  docs={count}  complete={complete_docs}/{count}")
    return 0


def cmd_chunker_list(args: argparse.Namespace) -> int:
    for name, source in list_methods():
        if args.verbose:
            print(f"{name}\t{source}")
        else:
            print(name)
    return 0


def cmd_inspect_hash(args: argparse.Namespace) -> int:
    store = _store_from_cwd()
    if not store.objects_dir().exists():
        print("store not initialized")
        return 1
    blob = store.get(args.hash)
    if blob is None:
        print("object not found in local store")
        return 1
    try:
        obj = load_manifest_object(blob)
        print("manifest:")
        print(json.dumps(obj, sort_keys=True, indent=2))
        return 0
    except ValueError:
        preview = binascii.hexlify(blob[:32]).decode("ascii")
        print("chunk:")
        print(f"  hash: {args.hash}")
        print(f"  size: {len(blob)} bytes")
        print(f"  preview: {preview}")
        return 0


def cmd_list(args: argparse.Namespace) -> int:
    store = _store_from_cwd()
    if not store.objects_dir().exists():
        print("store not initialized")
        return 1

    index = _index_from_cwd()
    data = index.load()

    if args.objects:
        for obj in store.objects_dir().rglob("*"):
            if obj.is_file():
                print(obj.name)
        return 0

    if args.documents:
        for doc_id in sorted(data.get("documents", {}).keys()):
            print(doc_id)
        return 0

    if args.document_manifests:
        for h in sorted(_iter_manifest_hashes(store, "doc-manifest")):
            print(h)
        return 0

    if args.collection_manifests:
        for h in sorted(_iter_manifest_hashes(store, "collection-manifest")):
            print(h)
        return 0

    if args.derived_manifests:
        for h in sorted(_iter_manifest_hashes(store, "derived-manifest")):
            print(h)
        return 0

    return 0


def _iter_manifest_hashes(store: ObjectStore, manifest_type: str) -> list[str]:
    matches: list[str] = []
    for obj in store.objects_dir().rglob("*"):
        if not obj.is_file():
            continue
        try:
            blob = obj.read_bytes()
            manifest = load_manifest_object(blob)
        except (OSError, ValueError):
            continue
        if manifest.get("type") == manifest_type:
            matches.append(obj.name)
    return matches


def build_parser() -> argparse.ArgumentParser:
    p = argparse.ArgumentParser(
        prog="gnos",
        description="Local-first verifier for content-addressed documents.",
    )
    sub = p.add_subparsers(dest="cmd", required=True)

    sub_init = sub.add_parser(
        "init",
        help="Initialize a local .gnos store.",
        description="Initialize a local .gnos store (object store).",
    )
    sub_init.set_defaults(func=cmd_init)

    sub_add = sub.add_parser(
        "add",
        help="Ingest files into the local store (atomic).",
        description=(
            "Ingest a file or directory into the local store. "
            "Performs round-trip identity enforcement and commits atomically."
        ),
    )
    sub_add.add_argument("path", help="File/dir path to add, or '-' to read bytes from stdin.")
    sub_add.add_argument("--format", default="auto", help="Format hint (default: auto).")
    sub_add.add_argument("--chunking", default="auto", help="Chunking method (default: auto).")
    sub_add.add_argument(
        "--chunking-params",
        default="",
        help="Chunking params as comma-separated key=value pairs (default: empty).",
    )
    sub_add.add_argument("--dry-run", action="store_true", help="Verify and stage, but do not commit anything.")
    sub_add.add_argument("--no-recursive", action="store_true", help="Do not recurse into directories.")
    sub_add.add_argument("--collection", "-C", action="store_true", help="Also emit a collection-manifest.")
    sub_add.add_argument("--verbose", action="store_true", help="Print details on success.")
    sub_add.set_defaults(func=cmd_add)

    sub_attest = sub.add_parser(
        "attest",
        help="Establish proof for a claim.",
        description="Attest coherence of manifests/chunks against document or collection identities.",
    )
    attest_sub = sub_attest.add_subparsers(dest="kind", required=True)

    sub_attest_manifest = attest_sub.add_parser(
        "manifest",
        help="Attest a specific manifest by hash.",
        description="Attest a specific manifest by its manifest hash.",
    )
    sub_attest_manifest.add_argument("manifest_hash")
    sub_attest_manifest.add_argument("--doc-id", default=None, help="Optional expected doc_id.")
    sub_attest_manifest.add_argument(
        "--shallow",
        action="store_true",
        help="Verify manifest integrity only (hash of canonical JSON).",
    )
    sub_attest_manifest.set_defaults(func=cmd_attest_manifest)

    sub_attest_file = attest_sub.add_parser(
        "file",
        help="Attest that a file matches a doc_id.",
        description="Attest that a file's canonical bytes hash to the given doc_id.",
    )
    sub_attest_file.add_argument("path")
    sub_attest_file.add_argument("--doc-id", required=True, help="Expected doc_id.")
    sub_attest_file.set_defaults(func=cmd_attest_file)

    sub_attest_doc = attest_sub.add_parser(
        "doc",
        help="Attest a document by doc_id using the local index.",
        description="Attest a document by doc_id using the local index to select a manifest.",
    )
    sub_attest_doc.add_argument("doc_id")
    sub_attest_doc.set_defaults(func=cmd_attest_doc)

    sub_materialize = sub.add_parser(
        "materialize",
        help="Reconstruct bytes to disk from the local store.",
        description="Materialize documents or collections to the filesystem.",
    )
    materialize_sub = sub_materialize.add_subparsers(dest="kind", required=True)

    sub_materialize_doc = materialize_sub.add_parser(
        "doc",
        help="Write a document (by doc_id) to a file.",
        description="Materialize a document identified by doc_id to an output path.",
    )
    sub_materialize_doc.add_argument("doc_id")
    sub_materialize_doc.add_argument("--output", required=True, help="Output file path (use '-' for stdout).")
    sub_materialize_doc.set_defaults(func=cmd_materialize_doc)

    sub_materialize_collection = materialize_sub.add_parser(
        "collection",
        help="Write all documents in a collection to a directory.",
        description="Materialize a collection identified by collection_id into an output directory.",
    )
    sub_materialize_collection.add_argument("collection_id")
    sub_materialize_collection.add_argument("--dir", required=True, help="Output directory.")
    sub_materialize_collection.set_defaults(func=cmd_materialize_collection)

    sub_derive = sub.add_parser(
        "derive",
        help="Derive a non-identity manifest from a doc-manifest.",
        description="Derive a non-identity manifest from a doc-manifest hash.",
    )
    sub_derive.add_argument("manifest_hash", help="Source doc-manifest hash.")
    sub_derive.add_argument("--chunking", required=True, help="Chunking method for derived output.")
    sub_derive.add_argument(
        "--chunking-params",
        default="",
        help="Chunking params as comma-separated key=value pairs (default: empty).",
    )
    sub_derive.add_argument("--dry-run", action="store_true", help="Verify and stage, but do not commit anything.")
    sub_derive.add_argument("--verbose", action="store_true", help="Print details on success.")
    sub_derive.set_defaults(func=cmd_derive)

    sub_status = sub.add_parser(
        "status",
        help="Show local store/index status (non-authoritative).",
        description="Show local store/index status (non-authoritative).",
    )
    sub_status.add_argument("--doc", dest="doc_id", default=None, help="Show status for a specific doc_id.")
    sub_status.add_argument("--collections", action="store_true", help="List collections and document counts.")
    sub_status.set_defaults(func=cmd_status)

    sub_chunker = sub.add_parser(
        "chunker",
        help="Chunker introspection.",
        description="List available chunking methods (core and entrypoint-provided).",
    )
    chunker_sub = sub_chunker.add_subparsers(dest="kind", required=True)

    sub_chunker_list = chunker_sub.add_parser(
        "list",
        help="List available chunkers.",
        description="List available chunking methods.",
    )
    sub_chunker_list.add_argument("--verbose", action="store_true", help="Include chunker source.")
    sub_chunker_list.set_defaults(func=cmd_chunker_list)

    sub_inspect = sub.add_parser(
        "inspect",
        help="Inspect a stored object by hash.",
        description="Inspect a stored object by hash (manifest JSON or chunk summary).",
    )
    sub_inspect.add_argument("hash", help="Object hash to inspect.")
    sub_inspect.set_defaults(func=cmd_inspect_hash)

    sub_list = sub.add_parser(
        "list",
        help="List objects/manifests/documents.",
        description="List stored objects or manifest/doc IDs.",
    )
    group = sub_list.add_mutually_exclusive_group(required=True)
    group.add_argument("--objects", action="store_true", help="List all object hashes in the store.")
    group.add_argument("--documents", action="store_true", help="List document IDs from the local index.")
    group.add_argument("--document-manifests", action="store_true", help="List doc-manifest hashes in the store.")
    group.add_argument("--collection-manifests", action="store_true", help="List collection-manifest hashes in the store.")
    group.add_argument("--derived-manifests", action="store_true", help="List derived-manifest hashes in the store.")
    sub_list.set_defaults(func=cmd_list)

    return p


def _print_version() -> None:
    try:
        print(pkg_version("gnos"))
    except PackageNotFoundError:
        print("0.0.0")


def main(argv: list[str] | None = None) -> int:
    if argv is not None and "--version" in argv:
        _print_version()
        return 0
    if argv is None and "--version" in sys.argv:
        _print_version()
        return 0
    parser = build_parser()
    args = parser.parse_args(argv)
    return args.func(args)


if __name__ == "__main__":
    raise SystemExit(main())
