"""CertiSigma Census CLI — command-line interface.

Usage:
    census scan /path/to/files --source inventory-hr
    census compare /path/to/suspect --manifest ./manifest.json
    census status --manifest ./manifest.json
"""

from __future__ import annotations

import csv
import json
import logging
import os
import re
import sys
from contextlib import nullcontext
from pathlib import Path

import click

from . import __version__

logger = logging.getLogger("certisigma_census")

_SIZE_UNITS: dict[str, int] = {
    "B": 1,
    "K": 1024,
    "KB": 1024,
    "M": 1024 ** 2,
    "MB": 1024 ** 2,
    "G": 1024 ** 3,
    "GB": 1024 ** 3,
    "T": 1024 ** 4,
    "TB": 1024 ** 4,
}
_SIZE_RE = re.compile(r"^\s*(\d+(?:\.\d+)?)\s*([A-Za-z]*)\s*$")


def _parse_size(value: str) -> int:
    """Parse a human-readable size string into bytes.

    Accepts: ``100``, ``512K``, ``10MB``, ``1.5G``.
    Raises ``click.BadParameter`` on invalid input.
    """
    m = _SIZE_RE.match(value)
    if not m:
        raise click.BadParameter(f"Invalid size: {value!r}. Use e.g. 100, 512K, 10M, 1G.")
    number, unit = float(m.group(1)), m.group(2).upper()
    if not unit:
        return int(number)
    if unit not in _SIZE_UNITS:
        raise click.BadParameter(
            f"Unknown size unit: {unit!r}. Valid: {', '.join(sorted(_SIZE_UNITS))}."
        )
    return int(number * _SIZE_UNITS[unit])


class _JsonLogFormatter(logging.Formatter):
    """Emit one JSON object per log line — ready for ELK/Splunk/SIEM ingest."""

    def format(self, record: logging.LogRecord) -> str:
        entry = {
            "timestamp": self.formatTime(record, datefmt="%Y-%m-%dT%H:%M:%S"),
            "level": record.levelname,
            "module": record.module,
            "message": record.getMessage(),
        }
        if record.exc_info and record.exc_info[1]:
            entry["exception"] = self.formatException(record.exc_info)
        return json.dumps(entry, ensure_ascii=False)


def _setup_logging(verbose: bool, log_format: str = "text") -> None:
    level = logging.DEBUG if verbose else logging.INFO
    handler = logging.StreamHandler(sys.stderr)
    handler.setLevel(level)

    if log_format == "json":
        handler.setFormatter(_JsonLogFormatter())
    else:
        handler.setFormatter(logging.Formatter(
            "%(asctime)s %(levelname)-8s %(message)s", datefmt="%H:%M:%S",
        ))

    root_logger = logging.getLogger("certisigma_census")
    root_logger.setLevel(level)
    root_logger.addHandler(handler)


def _get_client(api_key: str | None = None, base_url: str | None = None):
    from certisigma import CertiSigmaClient

    key = api_key or os.environ.get("CERTISIGMA_API_KEY")
    kwargs: dict = {}
    if key:
        kwargs["api_key"] = key
    if base_url:
        kwargs["base_url"] = base_url
    return CertiSigmaClient(**kwargs)


def _progress(items: list, *, label: str, enabled: bool):
    """Wrap *items* with ``click.progressbar`` when *enabled*, else pass through."""
    if enabled and items:
        return click.progressbar(items, label=label, show_pos=True)
    return nullcontext(items)


BATCH_SIZE = 100


@click.group()
@click.version_option(__version__, prog_name="certisigma-census")
@click.option("-v", "--verbose", is_flag=True, help="Enable debug logging.")
@click.option("--log-format", "log_format", type=click.Choice(["text", "json"]), default="text", help="Log output format (default: text).")
@click.pass_context
def main(ctx: click.Context, verbose: bool, log_format: str) -> None:
    """CertiSigma Census — Cryptographic file inventory & breach detection."""
    _setup_logging(verbose, log_format=log_format)
    ctx.ensure_object(dict)
    ctx.obj["verbose"] = verbose


@main.command()
@click.argument("directory", type=click.Path(exists=True, file_okay=False, resolve_path=True))
@click.option("--source", default=None, help="Source label for attestations (e.g. 'inventory-hr').")
@click.option("--manifest", "manifest_path", default=None, type=click.Path(), help="Path to save the manifest. Default: <directory>/.census-manifest.json")
@click.option("--api-key", default=None, envvar="CERTISIGMA_API_KEY", help="CertiSigma API key.")
@click.option("--base-url", default=None, help="API base URL override.")
@click.option("--dry-run", is_flag=True, help="Scan and hash only, do not attest.")
@click.option("--resume", is_flag=True, help="Resume interrupted scan: skip unchanged files already in manifest.")
@click.option("--include", "include_globs", multiple=True, help="Only include files matching this glob (repeatable, e.g. --include '*.pdf').")
@click.option("--exclude", "exclude_globs", multiple=True, help="Exclude files matching this glob (repeatable, e.g. --exclude '*.log').")
@click.option("--min-size", "min_size_str", default=None, help="Skip files smaller than this (e.g. 1K, 10M).")
@click.option("--max-size", "max_size_str", default=None, help="Skip files larger than this (e.g. 100M, 1G). Default: 5G.")
@click.pass_context
def scan(
    ctx: click.Context,
    directory: str,
    source: str | None,
    manifest_path: str | None,
    api_key: str | None,
    base_url: str | None,
    dry_run: bool,
    resume: bool,
    include_globs: tuple[str, ...],
    exclude_globs: tuple[str, ...],
    min_size_str: str | None,
    max_size_str: str | None,
) -> None:
    """Scan a directory, compute hashes, and attest them via CertiSigma."""
    from .manifest import Manifest
    from .scanner import DEFAULT_MAX_FILE_SIZE, scan_directory

    verbose = ctx.obj.get("verbose", False)
    show_progress = not verbose

    min_size = _parse_size(min_size_str) if min_size_str else 0
    max_size = _parse_size(max_size_str) if max_size_str else DEFAULT_MAX_FILE_SIZE

    root = Path(directory)
    mpath = Path(manifest_path) if manifest_path else root / ".census-manifest.json"

    existing_manifest = None
    if resume and mpath.exists():
        existing_manifest = Manifest.load(mpath)
        click.echo(f"Resuming from {mpath} ({existing_manifest.total} cached entries)")

    click.echo(f"Scanning {root} ...")
    manifest = scan_directory(
        root,
        show_progress=show_progress,
        include_globs=include_globs,
        exclude_globs=exclude_globs,
        min_file_size=min_size,
        max_file_size=max_size,
        existing_manifest=existing_manifest,
    )

    if existing_manifest:
        existing_manifest.merge(manifest)
        manifest = existing_manifest

    click.echo(f"Found {manifest.total} files.")

    if dry_run:
        manifest.save(mpath)
        click.echo(f"Dry run — manifest saved to {mpath}")
        return

    client = _get_client(api_key, base_url)
    hashes = list(manifest.entries.keys())
    attested = 0

    from .retry import retry_api_call

    hashes_to_attest = [h for h, e in manifest.entries.items() if not e.attested]
    batches = [hashes_to_attest[i : i + BATCH_SIZE] for i in range(0, len(hashes_to_attest), BATCH_SIZE)]
    with _progress(batches, label="Attesting", enabled=show_progress) as bar:
        for batch in bar:
            try:
                result = retry_api_call(client.batch_attest, batch, source=source)
                for att in result.attestations:
                    att_id = att.get("attestation_id") or att.get("id")
                    h = att.get("hash_hex", "")
                    ts = att.get("timestamp", att.get("registered_at", ""))
                    if att_id and h:
                        manifest.mark_attested(h, str(att_id), ts)
                        attested += 1
            except Exception as exc:
                logger.error("Batch attest failed: %s", exc)
            manifest.save(mpath)

    click.echo(f"Attested {attested}/{manifest.total} files. Manifest: {mpath}")


@main.command()
@click.argument("suspect_dir", type=click.Path(exists=True, file_okay=False, resolve_path=True))
@click.option("--manifest", "manifest_path", default=None, type=click.Path(exists=True), help="Local manifest for cross-referencing original paths.")
@click.option("--api-key", default=None, envvar="CERTISIGMA_API_KEY", help="CertiSigma API key.")
@click.option("--base-url", default=None, help="API base URL override.")
@click.option("--output", "output_path", default=None, type=click.Path(), help="Save report as JSON or CSV (detected by extension).")
@click.option("--include", "include_globs", multiple=True, help="Only include files matching this glob (repeatable).")
@click.option("--exclude", "exclude_globs", multiple=True, help="Exclude files matching this glob (repeatable).")
@click.option("--min-size", "min_size_str", default=None, help="Skip files smaller than this (e.g. 1K, 10M).")
@click.option("--max-size", "max_size_str", default=None, help="Skip files larger than this (e.g. 100M, 1G).")
@click.pass_context
def compare(
    ctx: click.Context,
    suspect_dir: str,
    manifest_path: str | None,
    api_key: str | None,
    base_url: str | None,
    output_path: str | None,
    include_globs: tuple[str, ...],
    exclude_globs: tuple[str, ...],
    min_size_str: str | None,
    max_size_str: str | None,
) -> None:
    """Compare suspect files against the CertiSigma registry."""
    from .compare import compare_directory
    from .manifest import Manifest
    from .scanner import DEFAULT_MAX_FILE_SIZE

    verbose = ctx.obj.get("verbose", False)
    show_progress = not verbose

    min_size = _parse_size(min_size_str) if min_size_str else 0
    max_size = _parse_size(max_size_str) if max_size_str else DEFAULT_MAX_FILE_SIZE

    local_manifest = None
    if manifest_path:
        local_manifest = Manifest.load(Path(manifest_path))

    client = _get_client(api_key, base_url)
    report = compare_directory(
        Path(suspect_dir), client, local_manifest=local_manifest,
        show_progress=show_progress,
        include_globs=include_globs,
        exclude_globs=exclude_globs,
        min_file_size=min_size,
        max_file_size=max_size,
    )

    click.echo(f"\nSuspect files:  {report.total_suspect}")
    click.echo(f"Matched:        {report.matched}")
    click.echo(f"Not matched:    {report.not_matched}")
    click.echo(f"Errors:         {report.errors}")

    if report.matches:
        click.echo("\n--- Matches ---")
        for m in report.matches:
            inv = f" (inventory: {m.inventory_path})" if m.inventory_path else ""
            click.echo(f"  {m.suspect_path} → att_{m.attestation_id} [{m.level}]{inv}")

    if output_path:
        _write_compare_report(report, Path(output_path))
        click.echo(f"\nReport saved to {output_path}")

    sys.exit(0 if report.matched == 0 else 1)


def _write_compare_report(report, path: Path) -> None:
    """Write a CompareReport as JSON or CSV (detected by file extension)."""
    from dataclasses import asdict

    if path.suffix.lower() == ".csv":
        fieldnames = [
            "suspect_path", "hash_hex", "matched", "attestation_id",
            "level", "timestamp", "inventory_path",
        ]
        with path.open("w", newline="", encoding="utf-8") as f:
            writer = csv.DictWriter(f, fieldnames=fieldnames)
            writer.writeheader()
            for m in report.matches:
                writer.writerow({
                    "suspect_path": m.suspect_path,
                    "hash_hex": m.hash_hex,
                    "matched": "true",
                    "attestation_id": m.attestation_id or "",
                    "level": m.level or "",
                    "timestamp": m.timestamp or "",
                    "inventory_path": m.inventory_path or "",
                })
    else:
        data = asdict(report)
        path.write_text(json.dumps(data, indent=2), encoding="utf-8")


@main.command()
@click.argument("manifest_path", type=click.Path(exists=True))
def status(manifest_path: str) -> None:
    """Show manifest status (total files, attested, pending)."""
    from .manifest import Manifest

    manifest = Manifest.load(Path(manifest_path))
    pending = manifest.total - manifest.attested_count

    click.echo(f"Manifest: {manifest_path}")
    click.echo(f"Root:     {manifest.root_dir}")
    click.echo(f"Total:    {manifest.total}")
    click.echo(f"Attested: {manifest.attested_count}")
    click.echo(f"Pending:  {pending}")


@main.command(name="export")
@click.argument("manifest_path", type=click.Path(exists=True))
@click.option("--format", "fmt", type=click.Choice(["csv", "json"]), default="csv", help="Output format (default: csv).")
@click.option("--output", "output_path", default=None, type=click.Path(), help="Output file. Default: stdout.")
def export_manifest(manifest_path: str, fmt: str, output_path: str | None) -> None:
    """Export a manifest as CSV or JSON."""
    from .manifest import Manifest

    manifest = Manifest.load(Path(manifest_path))

    if fmt == "csv":
        _export_manifest_csv(manifest, output_path)
    else:
        _export_manifest_json(manifest, output_path)


def _export_manifest_csv(manifest, output_path: str | None) -> None:
    from dataclasses import asdict

    fieldnames = [
        "hash_hex", "path", "size", "mtime",
        "attested", "attestation_id", "attested_at",
    ]
    if output_path:
        f = open(output_path, "w", newline="", encoding="utf-8")
    else:
        f = sys.stdout

    try:
        writer = csv.DictWriter(f, fieldnames=fieldnames)
        writer.writeheader()
        for entry in manifest.entries.values():
            writer.writerow(asdict(entry))
    finally:
        if output_path:
            f.close()


def _export_manifest_json(manifest, output_path: str | None) -> None:
    from dataclasses import asdict

    data = {
        "version": manifest.version,
        "root_dir": manifest.root_dir,
        "total": manifest.total,
        "attested": manifest.attested_count,
        "entries": [asdict(e) for e in manifest.entries.values()],
    }
    text = json.dumps(data, indent=2, ensure_ascii=False)
    if output_path:
        Path(output_path).write_text(text, encoding="utf-8")
    else:
        click.echo(text)


if __name__ == "__main__":
    main()
