from __future__ import annotations

import argparse
import json
import logging
import shlex
import shutil
import socket
import subprocess
from datetime import datetime
from pathlib import Path
from typing import Any, Iterable

from . import __version__
from .classifier import classify_compose
from .discovery import find_compose_files

LOGGER = logging.getLogger("dockervault")


def setup_logging(verbose: bool = False) -> None:
    level = logging.DEBUG if verbose else logging.INFO
    logging.basicConfig(level=level, format="%(levelname)s: %(message)s")


def safe_get(obj: Any, key: str, default: Any = None) -> Any:
    if obj is None:
        return default
    if isinstance(obj, dict):
        return obj.get(key, default)
    return getattr(obj, key, default)


def normalize_entries(entries: Any) -> list[dict[str, Any]]:
    if not entries:
        return []

    if not isinstance(entries, (list, tuple)):
        entries = [entries]

    normalized: list[dict[str, Any]] = []

    for entry in entries:
        if isinstance(entry, dict):
            normalized.append(
                {
                    "path": entry.get("path") or entry.get("source") or entry.get("host_path"),
                    "priority": entry.get("priority") or entry.get("classification"),
                    "service": entry.get("service"),
                    "target": entry.get("target") or entry.get("container_path"),
                    "source_type": entry.get("source_type"),
                    "reason": entry.get("reason"),
                    "exists": entry.get("exists"),
                    "compose_file": entry.get("compose_file"),
                }
            )
        else:
            normalized.append(
                {
                    "path": safe_get(entry, "path", safe_get(entry, "source")),
                    "priority": safe_get(entry, "priority", safe_get(entry, "classification")),
                    "service": safe_get(entry, "service"),
                    "target": safe_get(entry, "target", safe_get(entry, "container_path")),
                    "source_type": safe_get(entry, "source_type"),
                    "reason": safe_get(entry, "reason"),
                    "exists": safe_get(entry, "exists"),
                    "compose_file": safe_get(entry, "compose_file"),
                }
            )

    return normalized


def classify_entries(
    raw_entries: Any,
) -> tuple[list[dict[str, Any]], list[dict[str, Any]], list[dict[str, Any]]]:
    entries = normalize_entries(raw_entries)

    include_entries: list[dict[str, Any]] = []
    review_entries: list[dict[str, Any]] = []
    skip_entries: list[dict[str, Any]] = []

    for entry in entries:
        classification = str(entry.get("priority") or "").strip().lower()

        if classification == "critical":
            include_entries.append(entry)
        elif classification in {"optional", "skip", "ignored"}:
            skip_entries.append(entry)
        else:
            review_entries.append(entry)

    return include_entries, review_entries, skip_entries


def dedupe_entries(entries: Iterable[dict[str, Any]]) -> list[dict[str, Any]]:
    deduped: list[dict[str, Any]] = []
    seen: set[str] = set()

    for entry in entries:
        path = entry.get("path")
        if not path:
            continue

        key = str(path)
        if key in seen:
            continue

        seen.add(key)
        deduped.append(entry)

    return deduped


def extract_paths(entries: Iterable[dict[str, Any]]) -> list[str]:
    return [str(entry["path"]) for entry in dedupe_entries(entries) if entry.get("path")]


def find_missing_entries(entries: Iterable[dict[str, Any]]) -> list[dict[str, Any]]:
    return [entry for entry in entries if entry.get("exists") is False]


def entry_to_line(entry: dict[str, Any]) -> str:
    path = entry.get("path") or "(unknown)"
    priority = entry.get("priority") or "unknown"
    service = entry.get("service") or "unknown"
    target = entry.get("target") or "unknown"
    exists = entry.get("exists")
    compose_file = entry.get("compose_file")

    extra = []
    if compose_file:
        extra.append(f"compose={compose_file}")
    if entry.get("source_type"):
        extra.append(f"type={entry['source_type']}")
    if exists is not None:
        extra.append(f"exists={exists}")
    if entry.get("reason"):
        extra.append(f"reason={entry['reason']}")

    suffix = f" ({', '.join(extra)})" if extra else ""
    return f"  - {path} [{priority}] service={service} target={target}{suffix}"


def default_archive_name() -> str:
    hostname = socket.gethostname()
    now = datetime.now().strftime("%Y-%m-%d_%H-%M")
    return f"{hostname}-{now}"


def build_borg_command(repo: str, archive_name: str, include_paths: list[str]) -> list[str]:
    if not repo or not include_paths:
        return []

    cmd = [
        "borg",
        "create",
        "--stats",
        "--progress",
        f"{repo}::{archive_name}",
    ]
    cmd.extend(include_paths)
    return cmd


def ensure_borg_available() -> bool:
    if shutil.which("borg") is None:
        LOGGER.error("Borg binary not found in PATH")
        return False
    return True


def scan_projects(scan_root: Path) -> tuple[list[Path], list[dict[str, Any]]]:
    compose_files = find_compose_files(scan_root)
    all_entries: list[dict[str, Any]] = []

    for compose_file in compose_files:
        raw_entries = classify_compose(compose_file)
        normalized = normalize_entries(raw_entries)

        for entry in normalized:
            entry["compose_file"] = str(compose_file.resolve())
            all_entries.append(entry)

    return compose_files, all_entries


def print_human_plan(
    raw_entries: Any,
    label: str,
    root_path: Path,
    compose_files: list[Path] | None = None,
) -> None:
    include_entries, review_entries, skip_entries = classify_entries(raw_entries)

    include_entries = dedupe_entries(include_entries)
    review_entries = dedupe_entries(review_entries)
    skip_entries = dedupe_entries(skip_entries)

    missing_include = find_missing_entries(include_entries)

    print()
    print("DockerVault Backup Plan")
    print("=======================")
    print(f"{label}: {root_path.resolve()}")

    if compose_files is not None:
        print(f"Compose files found: {len(compose_files)}")

    print()

    print("INCLUDE PATHS:")
    if include_entries:
        for entry in include_entries:
            print(entry_to_line(entry))
    else:
        print("  - (none)")
    print()

    if missing_include:
        print("WARNING: Missing critical paths detected")
        for entry in missing_include:
            print(f"  - {entry.get('path')} (service={entry.get('service')})")
        print()

    print("REVIEW PATHS:")
    if review_entries:
        for entry in review_entries:
            print(entry_to_line(entry))
    else:
        print("  - (none)")
    print()

    print("SKIP PATHS:")
    if skip_entries:
        for entry in skip_entries:
            print(entry_to_line(entry))
    else:
        print("  - (none)")
    print()


def print_automation_output(
    raw_entries: Any,
    root_path: Path,
    repo: str | None = None,
    compose_files: list[Path] | None = None,
) -> None:
    include_entries, review_entries, skip_entries = classify_entries(raw_entries)

    include_entries = dedupe_entries(include_entries)
    review_entries = dedupe_entries(review_entries)
    skip_entries = dedupe_entries(skip_entries)

    include_paths = extract_paths(include_entries)
    missing_include = find_missing_entries(include_entries)

    payload: dict[str, Any] = {
        "root": str(root_path.resolve()),
        "include_paths": include_paths,
        "review_paths": [str(e["path"]) for e in review_entries if e.get("path")],
        "skip_paths": [str(e["path"]) for e in skip_entries if e.get("path")],
        "missing_critical_paths": [str(e["path"]) for e in missing_include if e.get("path")],
    }

    if compose_files is not None:
        payload["compose_files"] = [str(p.resolve()) for p in compose_files]

    if repo:
        archive_name = default_archive_name()
        payload["borg_repo"] = repo
        payload["suggested_archive_name"] = archive_name
        payload["borg_command"] = build_borg_command(
            repo=repo,
            archive_name=archive_name,
            include_paths=include_paths,
        )

    print(json.dumps(payload, indent=2))


def run_borg(repo: str, include_paths: list[str], dry_run: bool = False) -> int:
    if not include_paths:
        LOGGER.warning("No include paths found. Nothing to back up.")
        return 0

    if not ensure_borg_available():
        return 3

    archive_name = default_archive_name()
    cmd = build_borg_command(repo=repo, archive_name=archive_name, include_paths=include_paths)

    if dry_run:
        print(" ".join(shlex.quote(part) for part in cmd))
        return 0

    LOGGER.info("Borg repository: %s", repo)
    LOGGER.info("Archive name: %s", archive_name)
    LOGGER.info("Running borg backup...")

    result = subprocess.run(cmd, text=True, capture_output=True)

    if result.stdout:
        print(result.stdout, end="")

    if result.returncode != 0:
        stderr = (result.stderr or "").strip()
        if stderr:
            LOGGER.error("Borg failed: %s", stderr)
        else:
            LOGGER.error("Borg exited with status %s", result.returncode)
        return result.returncode

    LOGGER.info("Borg backup completed successfully.")
    return 0


def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
    common = argparse.ArgumentParser(add_help=False)
    common.add_argument("--repo", help="Borg repository path")
    common.add_argument(
        "--run-borg",
        action="store_true",
        help="Run borg create after building the backup plan",
    )
    common.add_argument(
        "--dry-run",
        action="store_true",
        help="Show borg command without executing it",
    )
    common.add_argument(
        "--automation",
        action="store_true",
        help="Output machine-readable JSON",
    )
    common.add_argument(
        "--quiet",
        action="store_true",
        help="Suppress normal human-readable plan output",
    )
    common.add_argument(
        "--verbose",
        action="store_true",
        help="Enable verbose logging",
    )

    parser = argparse.ArgumentParser(
        prog="dockervault",
        description="Intelligent Docker backup discovery with Borg integration",
        parents=[common],
    )

    subparsers = parser.add_subparsers(dest="command")

    plan_parser = subparsers.add_parser(
        "plan",
        help="Analyze a single compose file",
        parents=[common],
    )
    plan_parser.add_argument("compose", help="Path to docker-compose.yml or compose.yaml")

    scan_parser = subparsers.add_parser(
        "scan",
        help="Scan a directory for compose files",
        parents=[common],
    )
    scan_parser.add_argument("root", help="Root directory to scan")

    parser.add_argument(
        "--version",
        action="version",
        version=f"%(prog)s {__version__}",
    )

    parser.add_argument(
        "legacy_target",
        nargs="?",
        help=argparse.SUPPRESS,
    )

    args = parser.parse_args(argv)

    if args.command is None and args.legacy_target:
        args.command = "plan"
        args.compose = args.legacy_target

    if args.command is None:
        parser.error("You must provide either a compose file or use the 'scan' subcommand.")

    return args


def main(argv: list[str] | None = None) -> int:
    args = parse_args(argv)
    setup_logging(args.verbose)

    try:
        if args.command == "scan":
            root_path = Path(args.root)

            compose_files, raw_entries = scan_projects(root_path)

            if args.verbose:
                LOGGER.debug("Compose files found: %s", len(compose_files))

            include_entries, _, _ = classify_entries(raw_entries)
            include_entries = dedupe_entries(include_entries)
            include_paths = extract_paths(include_entries)
            missing_include = find_missing_entries(include_entries)

            if args.automation:
                print_automation_output(
                    raw_entries,
                    root_path=root_path,
                    repo=args.repo,
                    compose_files=compose_files,
                )
            elif not args.quiet:
                print_human_plan(
                    raw_entries,
                    label="Scan root",
                    root_path=root_path,
                    compose_files=compose_files,
                )

        else:
            compose_path = Path(args.compose)

            if not compose_path.exists():
                LOGGER.error("Compose file not found: %s", compose_path)
                return 1

            if not compose_path.is_file():
                LOGGER.error("Compose path is not a file: %s", compose_path)
                return 1

            raw_entries = classify_compose(compose_path)

            if args.verbose:
                LOGGER.debug("Raw plan type: %s", type(raw_entries))
                LOGGER.debug("Raw plan repr: %r", raw_entries)

            include_entries, _, _ = classify_entries(raw_entries)
            include_entries = dedupe_entries(include_entries)
            include_paths = extract_paths(include_entries)
            missing_include = find_missing_entries(include_entries)

            if args.automation:
                print_automation_output(
                    raw_entries,
                    root_path=compose_path,
                    repo=args.repo,
                )
            elif not args.quiet:
                print_human_plan(
                    raw_entries,
                    label="Compose file",
                    root_path=compose_path,
                )

        if not include_paths:
            LOGGER.warning("No include paths found. Nothing to back up.")
            return 0

        if args.run_borg:
            if not args.repo:
                LOGGER.error("--repo is required when using --run-borg")
                return 2

            if missing_include:
                LOGGER.error("Refusing to run borg: missing critical paths detected")
                return 4

            return run_borg(
                repo=args.repo,
                include_paths=include_paths,
                dry_run=args.dry_run,
            )

        if args.dry_run:
            if not args.repo:
                LOGGER.error("--repo is required when using --dry-run")
                return 2

            if not ensure_borg_available():
                return 3

            archive_name = default_archive_name()
            cmd = build_borg_command(args.repo, archive_name, include_paths)
            print(" ".join(shlex.quote(part) for part in cmd))
            return 0

        return 0

    except FileNotFoundError as exc:
        LOGGER.error("%s", exc)
        return 1
    except NotADirectoryError as exc:
        LOGGER.error("%s", exc)
        return 1
    except KeyboardInterrupt:
        LOGGER.error("Interrupted by user")
        return 130
    except Exception:
        LOGGER.exception("Unexpected error")
        return 99


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