"""CLI wrapper for graph workflows."""

from __future__ import annotations

import asyncio
from datetime import datetime
import json
from pathlib import Path
from typing import Any

import typer

try:
    from wordlift_sdk.validation import (
        extract_validation_issues as _sdk_extract_validation_issues,
        filter_validation_issues as _sdk_filter_validation_issues,
        resolve_shape_specs as _sdk_resolve_shape_specs,
        validate_file as _sdk_validate_file,
    )
except ImportError:
    from wordlift_sdk.validation import validate_file as _sdk_validate_file

    _sdk_extract_validation_issues = None
    _sdk_filter_validation_issues = None
    _sdk_resolve_shape_specs = None

try:
    from wordlift_sdk.graph.audit import AuditOptions as _AuditOptions
    from wordlift_sdk.graph.audit import GraphAuditor as _GraphAuditor
    from wordlift_sdk.graph.audit import shape_specs_for_profile as _shape_specs_for_profile
except ImportError:
    _AuditOptions = None
    _GraphAuditor = None
    _shape_specs_for_profile = None

try:
    from wordlift_sdk.utils import reset_me as _reset_me
except ImportError:
    _reset_me = None

from worai.config import resolve_config_path
from worai.core.output_template import render_output_path_template
from worai.core.graph import (
    GraphExportOptions,
    GraphSyncCreateOptions,
    GraphSyncOptions,
    PropertyDeleteOptions,
    run_graph_export,
    run_graph_sync,
    run_graph_sync_create,
    run_property_delete,
    validate_exported_graph,
)
from worai.core.profile_loader import load_profile, resolve_profile_api_key, resolve_profile_name
from worai.errors import UsageError

app = typer.Typer(add_completion=False, no_args_is_help=True)
sync_app = typer.Typer(add_completion=False, no_args_is_help=True)
property_app = typer.Typer(add_completion=False, no_args_is_help=True)


@sync_app.command(
    "run",
    help="Run graph sync for a configured profile.",
)
def run(
    ctx: typer.Context,
    debug: bool = typer.Option(
        False,
        "--debug",
        help="Write generated callback graphs to output/debug_cloud/<profile>/.",
    ),
) -> None:
    try:
        resolved_profile = resolve_profile_name(ctx)
        config_path = (ctx.obj or {}).get("config_path") if ctx else None
        run_graph_sync(
            GraphSyncOptions(
                profile=resolved_profile,
                config_path=config_path or resolve_config_path(None),
                debug=debug,
            )
        )
    except ValueError as exc:
        raise UsageError(str(exc)) from exc


@sync_app.command(
    "create",
    no_args_is_help=True,
    help="Create a graph sync project from a Copier template.",
)
def create(
    destination: Path | None = typer.Argument(
        None,
        metavar="DESTINATION",
        help="Output directory where the graph sync project will be created.",
    ),
    template: str = typer.Option(
        "gh:wordlift/graph-sync-template",
        "--template",
        help="Copier template source (Git URL, gh: shortcut, or local path).",
    ),
    defaults: bool = typer.Option(
        False,
        "--defaults",
        help="Use Copier default answers.",
    ),
    data_file: Path | None = typer.Option(
        None,
        "--data-file",
        help="Path to a Copier data file with pre-filled answers.",
    ),
    vcs_ref: str | None = typer.Option(
        None,
        "--vcs-ref",
        help="Template VCS ref (tag, branch, or commit).",
    ),
    non_interactive: bool = typer.Option(
        False,
        "--non-interactive",
        help="Disable prompts and fail if required answers are missing.",
    ),
    force: bool = typer.Option(
        False,
        "--force",
        help="Allow writing into existing destinations.",
    ),
) -> None:
    if destination is None:
        if non_interactive:
            raise UsageError("Destination path is required when using --non-interactive.")
        destination = Path(typer.prompt("Destination directory"))

    run_graph_sync_create(
        GraphSyncCreateOptions(
            destination=destination,
            template=template,
            defaults=defaults,
            data_file=data_file,
            vcs_ref=vcs_ref,
            non_interactive=non_interactive,
            force=force,
        )
    )


app.add_typer(sync_app, name="sync", help="Run graph sync workflows.")


def _normalize_level(level: str) -> str:
    value = level.strip().lower()
    if value not in {"warning", "error"}:
        raise UsageError("Invalid --level value. Use 'warning' or 'error'.")
    return value


def _normalize_output_format(value: str) -> str:
    normalized = value.strip().lower()
    if normalized not in {"text", "json"}:
        raise UsageError("Invalid --format value. Use 'text' or 'json'.")
    return normalized


def _resolve_shape_specs(
    builtin_shapes: list[str] | None,
    exclude_builtin_shapes: list[str] | None,
    extra_shapes: list[str] | None,
) -> list[str] | None:
    if _sdk_resolve_shape_specs is None:
        if builtin_shapes or exclude_builtin_shapes:
            raise UsageError(
                "--builtin-shape and --exclude-builtin-shape require wordlift-sdk>=6.1.0."
            )
        return extra_shapes
    return _sdk_resolve_shape_specs(
        builtin_shapes=builtin_shapes,
        exclude_builtin_shapes=exclude_builtin_shapes,
        extra_shapes=extra_shapes,
    )


def _issue_to_dict(issue: Any) -> dict[str, Any]:
    if isinstance(issue, dict):
        return dict(issue)
    return {
        "level": getattr(issue, "level", None),
        "severity": getattr(issue, "severity", None),
        "focus_node": getattr(issue, "focus_node", None),
        "result_path": getattr(issue, "result_path", None),
        "rule_id": getattr(issue, "rule_id", None),
        "rule_set": getattr(issue, "rule_set", None),
        "message": getattr(issue, "message", ""),
    }


def _filtered_issues(result: Any, level: str) -> list[Any]:
    if _sdk_extract_validation_issues and _sdk_filter_validation_issues:
        issues = _sdk_extract_validation_issues(result)
        return list(_sdk_filter_validation_issues(issues, level=level))
    if level == "warning":
        has_failure = (not result.conforms) or bool(result.warning_count)
    else:
        has_failure = not result.conforms
    if not has_failure:
        return []
    return [
        {
            "level": "error" if not result.conforms else "warning",
            "severity": None,
            "focus_node": None,
            "result_path": None,
            "rule_id": None,
            "rule_set": None,
            "message": (
                "Validation failed."
                if not result.conforms
                else "Validation contains warnings and --level=warning."
            ),
        }
    ]


@app.command(
    "validate",
    no_args_is_help=True,
    help="Validate one or more graph files/URLs with SHACL shapes.",
)
def validate(
    inputs: list[str] = typer.Argument(..., metavar="FILE_OR_URL"),
    builtin_shape: list[str] | None = typer.Option(
        None,
        "--builtin-shape",
        help="Built-in shape name to include. Repeat to include multiple.",
        show_default=False,
    ),
    exclude_builtin_shape: list[str] | None = typer.Option(
        None,
        "--exclude-builtin-shape",
        help="Built-in shape name to exclude. Repeat to exclude multiple.",
        show_default=False,
    ),
    shape: list[str] | None = typer.Option(
        None,
        "--shape",
        help="Extra local/remote shape file or URL. Repeat to add multiple.",
        show_default=False,
    ),
    level: str = typer.Option(
        "warning",
        "--level",
        help="Failure threshold: warning or error.",
        show_default=True,
    ),
    format: str = typer.Option(
        "text",
        "--format",
        help="Output format: text or json.",
        show_default=True,
    ),
) -> None:
    normalized_level = _normalize_level(level)
    normalized_format = _normalize_output_format(format)
    try:
        shape_specs = _resolve_shape_specs(builtin_shape, exclude_builtin_shape, shape)
    except RuntimeError as exc:
        raise UsageError(str(exc)) from exc

    has_failures = False
    payload: list[dict[str, Any]] = []

    for item in inputs:
        try:
            result = _sdk_validate_file(item, shape_specs=shape_specs)
        except RuntimeError as exc:
            raise UsageError(f"Failed to validate {item}: {exc}") from exc
        issues = _filtered_issues(result, normalized_level)
        if issues:
            has_failures = True

        issue_dicts = [_issue_to_dict(issue) for issue in issues]
        if normalized_format == "json":
            payload.append(
                {
                    "input": item,
                    "ok": len(issue_dicts) == 0,
                    "level": normalized_level,
                    "issue_count": len(issue_dicts),
                    "issues": issue_dicts,
                }
            )
            continue

        if not issue_dicts:
            typer.echo(f"{item}: OK")
            continue

        typer.echo(f"{item}: FAIL ({len(issue_dicts)} issue(s) at level >= {normalized_level})")
        for issue in issue_dicts:
            issue_level = str(issue.get("level") or "warning").upper()
            message = str(issue.get("message") or "").strip()
            result_path = issue.get("result_path")
            focus_node = issue.get("focus_node")
            details = []
            if result_path:
                details.append(f"path={result_path}")
            if focus_node:
                details.append(f"focus={focus_node}")
            suffix = f" ({', '.join(details)})" if details else ""
            typer.echo(f"- [{issue_level}] {message}{suffix}")

    if normalized_format == "json":
        output: Any = payload[0] if len(payload) == 1 else payload
        typer.echo(json.dumps(output, indent=2))

    if has_failures:
        raise UsageError(f"Validation failed at level '{normalized_level}'.")


def _default_export_output_path(profile: str) -> Path:
    return render_output_path_template(
        "export_{profile}_{date}_{seq}.ttl",
        profile_name=profile,
        now=datetime.now(),
    )


@app.command(
    "export",
    no_args_is_help=False,
    help="Export graph data from the WordLift /dataset/export endpoint.",
)
def export(
    ctx: typer.Context,
    output_file_name: str | None = typer.Argument(
        None,
        metavar="OUTPUT_FILE_NAME",
        help="Output file path. Extension controls export format (.ttl, .nt, .nq, .rdf, .xml, .jsonld, .json).",
    ),
    endpoint: str = typer.Option(
        "https://api.wordlift.io/dataset/export",
        "--endpoint",
        help="WordLift export endpoint.",
    ),
    timeout: int = typer.Option(120, "--timeout", help="HTTP timeout in seconds."),
    validate: bool = typer.Option(
        False,
        "--validate",
        help="Run SHACL validation on the exported graph file.",
    ),
) -> None:
    resolved_profile = resolve_profile_name(ctx)
    try:
        api_key, resolved_profile, _ = resolve_profile_api_key(ctx, explicit_profile=resolved_profile)
    except ValueError as exc:
        raise UsageError(str(exc)) from exc
    if not api_key:
        raise UsageError(
            f"WORDLIFT_API_KEY is required (or set profiles.{resolved_profile}.api_key in config)."
        )

    output_path = (
        Path(output_file_name)
        if output_file_name
        else _default_export_output_path(resolved_profile)
    )
    try:
        exported = run_graph_export(
            GraphExportOptions(
                api_key=api_key,
                output_path=output_path,
                endpoint=endpoint,
                timeout=timeout,
            )
        )
    except ValueError as exc:
        raise UsageError(str(exc)) from exc
    except RuntimeError as exc:
        raise UsageError(str(exc)) from exc

    if validate:
        try:
            validate_exported_graph(exported)
        except RuntimeError as exc:
            raise UsageError(str(exc)) from exc
        typer.echo(f"Validated graph export: {exported}")

    typer.echo(f"Exported graph to {exported}")


@property_app.command(
    "delete",
    no_args_is_help=True,
    help="Delete one predicate from all matching entities in the graph.",
)
def property_delete(
    ctx: typer.Context,
    predicate: str = typer.Argument(..., help="Predicate IRI or CURIE (for example, seovoc:html)."),
    endpoint: str = typer.Option(
        "https://api.wordlift.io/graphql", "--endpoint", help="WordLift GraphQL endpoint."
    ),
    entities_endpoint: str = typer.Option(
        "https://api.wordlift.io/entities", "--entities-endpoint", help="WordLift entities API endpoint."
    ),
    dry_run: bool = typer.Option(False, "--dry-run", help="List matches without applying changes."),
    yes: bool = typer.Option(False, "--yes", help="Skip confirmation prompt."),
    workers: int = typer.Option(4, "--workers", help="Number of concurrent PATCH requests."),
    retries: int = typer.Option(3, "--retries", help="Retries per failed PATCH request."),
    rate_delay: float = typer.Option(0.0, "--rate-delay", help="Delay in seconds after each successful PATCH."),
    limit: int = typer.Option(0, "--limit", help="Limit number of matching entities to process."),
    timeout: int = typer.Option(60, "--timeout", help="HTTP timeout in seconds."),
) -> None:
    try:
        api_key, _, _ = resolve_profile_api_key(ctx)
    except ValueError as exc:
        raise UsageError(str(exc)) from exc
    if not api_key:
        raise UsageError("WORDLIFT_API_KEY is required (or set profiles.<name>.api_key in config).")

    options = PropertyDeleteOptions(
        api_key=api_key,
        predicate=predicate,
        graphql_endpoint=endpoint,
        entities_endpoint=entities_endpoint,
        dry_run=True,
        workers=workers,
        retries=retries,
        rate_delay=rate_delay,
        limit=limit,
        timeout=timeout,
    )
    try:
        summary = run_property_delete(options)
    except ValueError as exc:
        raise UsageError(str(exc)) from exc

    typer.echo(f"Predicate: {summary.predicate}")
    typer.echo(f"Matching entities: {summary.candidates}")
    if dry_run:
        return

    if summary.candidates == 0:
        return

    if not yes:
        typer.confirm(
            f"Delete predicate {summary.predicate} from {summary.candidates} entities?",
            abort=True,
        )

    execute_options = PropertyDeleteOptions(
        api_key=options.api_key,
        predicate=options.predicate,
        graphql_endpoint=options.graphql_endpoint,
        entities_endpoint=options.entities_endpoint,
        dry_run=False,
        workers=options.workers,
        retries=options.retries,
        rate_delay=options.rate_delay,
        limit=options.limit,
        timeout=options.timeout,
    )
    result = run_property_delete(execute_options)
    typer.echo(f"Attempted: {result.attempted}")
    typer.echo(f"Removed: {result.removed}")
    typer.echo(f"Failed: {result.failed}")
    if result.failed:
        raise UsageError("Some entities failed to patch. Re-run with lower --workers or higher --retries.")


app.add_typer(property_app, name="property", help="Bulk predicate operations on the graph.")


_SCHEMA_COMPLIANCE_MARKER = "--- Schema Compliance ---"


def _render_audit_text(report: Any, *, show_url_violations: bool = False) -> str:
    """Render audit report text.

    By default the schema compliance section shows only a summary count.
    Pass show_url_violations=True to list each failing URL with its
    deduplicated SHACL errors.
    """
    full = report.to_text()
    if _SCHEMA_COMPLIANCE_MARKER not in full:
        return full

    pre, _ = full.split(_SCHEMA_COMPLIANCE_MARKER, 1)
    by_url = report.schema_compliance.by_url
    failing = [r for r in by_url if r.error_count > 0 or r.warning_count > 0]

    lines: list[str] = [pre.rstrip(), "", _SCHEMA_COMPLIANCE_MARKER]
    lines.append(f"  URLs checked : {len(by_url)}")
    lines.append(f"  URLs failing : {len(failing)}")

    if show_url_violations:
        lines.append("")
        for r in failing:
            lines.append(f"  [FAIL] {r.url}  (errors={r.error_count}, warnings={r.warning_count})")
            merchant = r.google_merchant
            lines.append(f"         merchant: {'eligible' if merchant.eligible else 'not eligible'}")
            for prefix, issues in (("ERROR", r.errors), ("WARN ", r.warnings)):
                seen: dict[str, list[str]] = {}
                for issue in issues:
                    seen.setdefault(issue.message, []).append(issue.focus_node)
                for message, focus_nodes in seen.items():
                    if len(focus_nodes) == 1:
                        lines.append(f"         {prefix}: {message} (focus={focus_nodes[0]})")
                    else:
                        lines.append(f"         {prefix}: {message} (×{len(focus_nodes)})")

    lines.append("")
    return "\n".join(lines)


def _normalize_rich_snippets_granularity(value: str) -> str:
    normalized = value.strip().lower()
    if normalized not in {"counts", "entities"}:
        raise UsageError("Invalid --rich-snippets-granularity value. Use 'counts' or 'entities'.")
    return normalized


@app.command(
    "audit",
    no_args_is_help=True,
    help="Audit a graph file for schema compliance, rich snippets, and connectivity.",
)
def audit(
    ctx: typer.Context,
    file: Path = typer.Argument(..., metavar="FILE", help="Path to the graph file (TTL, JSON-LD, NT, RDF/XML)."),
    depth: int = typer.Option(
        1,
        "--depth",
        help="Subgraph expansion depth for schema compliance.",
        show_default=True,
    ),
    list_isolated_iris: bool = typer.Option(
        False,
        "--list-isolated-iris",
        help="Include the IRI lists for each isolated subgraph in the report.",
    ),
    show_url_violations: bool = typer.Option(
        False,
        "--show-url-violations",
        help="List each failing URL with its SHACL violations.",
    ),
    rich_snippets_granularity: str = typer.Option(
        "counts",
        "--rich-snippets-granularity",
        help="Rich snippets granularity: counts or entities.",
        show_default=True,
    ),
    workers: int | None = typer.Option(
        None,
        "--workers",
        help="Max worker processes for SHACL validation (default: auto).",
        show_default=False,
    ),
    builtin_shape: list[str] | None = typer.Option(
        None,
        "--builtin-shape",
        help="Built-in shape to include (mutually exclusive with --exclude-builtin-shape). Repeat to include multiple.",
        show_default=False,
    ),
    exclude_builtin_shape: list[str] | None = typer.Option(
        None,
        "--exclude-builtin-shape",
        help="Built-in shape to exclude (mutually exclusive with --builtin-shape). Repeat to exclude multiple.",
        show_default=False,
    ),
    shape: list[str] | None = typer.Option(
        None,
        "--shape",
        help="Extra local .ttl shape file appended to the resolved set. Repeat to add multiple.",
        show_default=False,
    ),
    issue_level: str = typer.Option(
        "warning",
        "--issue-level",
        help="Issue level filter: warning or error.",
        show_default=True,
    ),
    format: str = typer.Option(
        "text",
        "--format",
        help="Output format: text or json.",
        show_default=True,
    ),
) -> None:
    if _GraphAuditor is None or _AuditOptions is None:
        raise UsageError("wordlift-sdk>=6.11.0 is required for 'graph audit'.")

    if builtin_shape and exclude_builtin_shape:
        raise UsageError("--builtin-shape and --exclude-builtin-shape are mutually exclusive.")

    normalized_format = _normalize_output_format(format)
    normalized_granularity = _normalize_rich_snippets_granularity(rich_snippets_granularity)
    normalized_issue_level = _normalize_level(issue_level)

    profile_name = (ctx.obj or {}).get("profile") if ctx else None
    profile_def = None
    if profile_name:
        try:
            profile_def, _, _ = load_profile(ctx)
        except ValueError as exc:
            raise UsageError(str(exc)) from exc

    options = _AuditOptions(
        profile=profile_def,
        subgraph_depth=depth,
        list_isolated_components=list_isolated_iris,
        rich_snippets_granularity=normalized_granularity,
        max_workers=workers,
        builtin_shapes=builtin_shape or None,
        exclude_builtin_shapes=exclude_builtin_shape or None,
        extra_shapes=shape or None,
        issue_level=normalized_issue_level,
    )

    try:
        report = _GraphAuditor().audit(str(file), options)
    except Exception as exc:
        msg = str(exc)
        if "builtin shape" in msg.lower() and _shape_specs_for_profile is not None:
            available = _shape_specs_for_profile(None)
            raise UsageError(f"{msg}\n\nAvailable built-in shapes:\n" + "\n".join(f"  {s}" for s in available)) from exc
        raise UsageError(f"Audit failed: {exc}") from exc

    if normalized_format == "json":
        typer.echo(json.dumps(report.to_dict(), indent=2))
    else:
        typer.echo(_render_audit_text(report, show_url_violations=show_url_violations))

    if report.load_errors:
        raise typer.Exit(1)


@app.command(
    "reset",
    help="Reset the WordLift account associated with the current profile.",
)
def reset(
    ctx: typer.Context,
    keep_country: bool = typer.Option(False, "--keep-country", help="Keep the account country on reset."),
    keep_language: bool = typer.Option(False, "--keep-language", help="Keep the account language on reset."),
    keep_url: bool = typer.Option(False, "--keep-url", help="Keep the account URL on reset."),
    yes: bool = typer.Option(False, "--yes", help="Skip the confirmation prompt."),
) -> None:
    if _reset_me is None:
        raise UsageError("wordlift-sdk>=6.12.0 is required for 'graph reset'.")

    try:
        api_key, _, _ = resolve_profile_api_key(ctx)
    except ValueError as exc:
        raise UsageError(str(exc)) from exc
    if not api_key:
        raise UsageError("WORDLIFT_API_KEY is required (or set profiles.<name>.api_key in config).")

    typer.echo("WARNING: this will reset your WordLift account. This operation is destructive and cannot be undone.")
    if not yes:
        typer.confirm("Are you sure you want to reset the account?", abort=True)

    from wordlift_client import Configuration

    configuration = Configuration(host="https://api.wordlift.io")
    configuration.api_key["ApiKey"] = api_key
    configuration.api_key_prefix["ApiKey"] = "Key"

    try:
        asyncio.run(
            _reset_me(
                configuration,
                keep_country=keep_country,
                keep_language=keep_language,
                keep_url=keep_url,
            )
        )
    except Exception as exc:
        raise UsageError(f"Graph reset failed: {exc}") from exc

    typer.echo("Graph reset complete.")
