"""
CLI entry point — Typer application.
"""

import asyncio
import json
import time
from pathlib import Path
from typing import Annotated, Optional

import fitz  # PyMuPDF
import networkx as nx
import typer
from rich.panel import Panel

from knwler.config import (
    DEFAULT_OLLAMA_EXTRACTION_MODEL,
    DEFAULT_OLLAMA_SCHEMA_MODEL,
    Config,
    ExtractionResult,
    Schema,
    console,
)
from knwler.language import (
    DEFAULT_LANGUAGE,
    get_console_msg,
    get_lang,
    set_language,
)
from knwler.cache import CACHE_DIR
from knwler.chunking import chunk_text
from knwler.community import analyze_communities, create_network
from knwler.consolidation import consolidate_graphs
from knwler.discovery import detect_language, discover_schema
from knwler.export import export_html
from knwler.extraction import extract_all
from knwler.extras import extract_summary, extract_title, rephrase_chunks
from knwler.stats import compute_community_stats, compute_stats, print_stats

app = typer.Typer(
    help="Extract knowledge graphs from text using Ollama or OpenAI.",
    rich_markup_mode="rich",
    no_args_is_help=True,
)


@app.command()
def main(
    ctx: typer.Context,
    file: Annotated[
        Optional[Path],
        typer.Option("--file", "-f", help="Path to a text or PDF file to extract from"),
    ] = None,
    extraction_model: Annotated[
        str,
        typer.Option(
            "--extraction-model",
            "-e",
            help=f"LLM model for graph extraction, default: {DEFAULT_OLLAMA_EXTRACTION_MODEL}",
        ),
    ] = DEFAULT_OLLAMA_EXTRACTION_MODEL,
    discovery_model: Annotated[
        str,
        typer.Option(
            "--discovery-model",
            "-d",
            help=f"LLM model for schema discovery, default: {DEFAULT_OLLAMA_SCHEMA_MODEL}",
        ),
    ] = DEFAULT_OLLAMA_SCHEMA_MODEL,
    concurrent: Annotated[
        int,
        typer.Option(
            "--concurrent", "-c", help="Max LLM concurrent requests, default: 10"
        ),
    ] = 10,
    no_discovery: Annotated[
        bool,
        typer.Option(
            "--no-discovery",
            help="Skip schema discovery via LLM and use the default (shallow) schema",
        ),
    ] = False,
    no_cache: Annotated[
        bool,
        typer.Option("--no-cache", help="Disable LLM response caching, default: false"),
    ] = False,
    openai: Annotated[
        bool,
        typer.Option(
            "--openai",
            help="Use OpenAI API instead of Ollama (requires the OPENAI_API_KEY set in environment), default: false",
        ),
    ] = False,
    openai_base_url: Annotated[
        str,
        typer.Option(
            "--openai-base-url",
            help="OpenAI API base URL, default: https://api.openai.com/v1",
        ),
    ] = "https://api.openai.com/v1",
    output: Annotated[
        Optional[Path],
        typer.Option(
            "--output",
            "-o",
            help="Directory to save results (defaults to timestamped dir in results/)",
        ),
    ] = None,
    max_tokens: Annotated[
        int,
        typer.Option("--max-tokens", help="Max tokens per chunk, default: 400"),
    ] = 400,
    html_report: Annotated[
        bool,
        typer.Option("--html-report", help="Also export an HTML report, default: true"),
    ] = True,
    gml_export: Annotated[
        bool,
        typer.Option(
            "--gml-export", help="Also export a GML graph file, default: true"
        ),
    ] = True,
    html_only: Annotated[
        bool,
        typer.Option(
            "--html-only",
            help="Only export HTML from existing an existing graph JSON file, default: false",
        ),
    ] = False,
    url: Annotated[
        Optional[str],
        typer.Option("--url", "-u", help="Origin of the document (for metadata only)"),
    ] = None,
    language: Annotated[
        Optional[str],
        typer.Option(
            "--language",
            "-l",
            help="Language to use (e.g., en, de, fr, es, nl). Auto-detects if not specified. Default: auto-detect",
        ),
    ] = None,
):
    """Extract knowledge graphs from text using LLMs."""

    # Determine output path
    if output is None:
        results_dir = Path(time.strftime("results/%Y%m%d-%H%M%S"))
        results_dir.mkdir(parents=True, exist_ok=True)
    else:
        if output.is_file():
            raise ValueError(f"Output path must be a directory, not a file: {output}")
        results_dir = output
        output.mkdir(parents=True, exist_ok=True)

    if html_only:
        graph_json_path = results_dir / "graph.json"
        if not graph_json_path.exists():
            raise FileNotFoundError(f"Missing results file: {graph_json_path}")
        results_data = json.loads(graph_json_path.read_text())
        saved_lang = results_data.get("language", DEFAULT_LANGUAGE)
        set_language(saved_lang)
        html_path = export_html(
            results_data, results_dir / "index.html", title=results_dir.stem
        )
        console.print(
            f"[green]\u2713[/green] HTML report saved to [cyan]{html_path}[/cyan]"
        )
        return

    # Config
    config = Config(
        ollama_extraction_model=extraction_model,
        ollama_discovery_model=discovery_model,
        max_concurrent=concurrent,
        max_tokens=max_tokens,
        use_cache=not no_cache,
        use_openai=openai,
        openai_base_url=openai_base_url,
    )

    # Load text
    if file is None:
        typer.echo(ctx.get_help())
        raise typer.Exit(1)
    file_path = file

    if file_path.suffix.lower() == ".pdf":
        extracted_text_path = results_dir / f"{file_path.stem}_extracted.txt"
        if extracted_text_path.exists():
            console.print(
                f"[green]\u2713[/green] Using cached extracted text: {extracted_text_path}"
            )
            text = extracted_text_path.read_text(encoding="utf-8", errors="ignore")
        else:
            console.print(f"[yellow]Extracting text from PDF: {file_path}[/yellow]")
            doc = fitz.open(file_path)
            text = "\n\n".join(page.get_text() for page in doc)
            extracted_text_path.write_text(text, encoding="utf-8", errors="ignore")
    else:
        text = file_path.read_text(encoding="utf-8", errors="ignore")

    # Load existing graph if output file already exists (augment mode)
    existing_data = None
    existing_result = None
    graph_json_path = results_dir / "graph.json"
    if graph_json_path.exists():
        existing_data = json.loads(graph_json_path.read_text())
        consolidated_ents = existing_data.get("graph", {}).get("entities", [])
        consolidated_rels = existing_data.get("graph", {}).get("relations", [])
        existing_result = ExtractionResult(
            entities=consolidated_ents,
            relations=consolidated_rels,
            chunk_idx=-1,
            chunk_time=0.0,
            chunk_tokens=0,
        )

    # Language detection / setting
    if language:
        set_language(language)
        detected_lang = language
    else:
        detected_lang = detect_language(text, config)
        set_language(detected_lang)

    lang_name = get_lang().get("name", detected_lang)

    # Show header
    backend = "[cyan]OpenAI[/cyan]" if config.use_openai else "[green]Ollama[/green]"
    mode = (
        f"  \u2022  [yellow]Augmenting[/yellow]: [dim]{output.name}[/dim]"
        if existing_data
        else ""
    )
    console.print(
        Panel.fit(
            f"[bold]Graph Extraction Pipeline[/bold]\n"
            f"Backend: {backend}  \u2022  File: [dim]{file_path.name}[/dim]  "
            f"\u2022  Language: [magenta]{lang_name}[/magenta]{mode}",
            border_style="blue",
        )
    )

    # Cache status
    if config.use_cache:
        cache_count = len(list(CACHE_DIR.glob("*.json"))) if CACHE_DIR.exists() else 0
        console.print(f"[dim]Cache: {CACHE_DIR} ({cache_count} entries)[/dim]")
    else:
        console.print("[dim]Cache: disabled[/dim]")

    # ── Schema discovery ──
    console.print()
    console.rule("[bold cyan]Schema Discovery[/bold cyan]")
    console.print(f"Model: [green]{config.discovery_model}[/green]")

    if no_discovery:
        schema = Schema(
            entity_types=config.default_entity_types,
            relation_types=config.default_relation_types,
            reasoning="Using defaults (discovery skipped)",
        )
        console.print("[yellow]Skipped (using defaults)[/yellow]")
    else:
        discovering_msg = (
            get_console_msg("discovering_schema") or "Discovering schema..."
        )
        with console.status(f"[bold green]{discovering_msg}[/bold green]"):
            schema = discover_schema(text, config)
        console.print(f"Time: [cyan]{schema.discovery_time:.2f}s[/cyan]")

    # Merge existing schema types
    if existing_data and "schema" in existing_data:
        old_schema = existing_data["schema"]
        for et in old_schema.get("entity_types", []):
            if et.lower() not in {t.lower() for t in schema.entity_types}:
                schema.entity_types.append(et)
        for rt in old_schema.get("relation_types", []):
            if rt.lower() not in {t.lower() for t in schema.relation_types}:
                schema.relation_types.append(rt)

    console.print(f"Entity types: [green]{', '.join(schema.entity_types)}[/green]")
    console.print(f"Relation types: [green]{', '.join(schema.relation_types)}[/green]")

    # ── Chunking ──
    console.print()
    console.rule("[bold cyan]Text Chunking[/bold cyan]")
    chunks = chunk_text(text, config)
    console.print(
        f"\nChunks: [cyan]{len(chunks)}[/cyan] (~{config.max_tokens} tokens each)"
    )

    # ── Title ──
    console.print()
    console.rule("[bold cyan]Title Extraction[/bold cyan]")
    title = extract_title(chunks, config) or file_path.stem
    console.print(f"Title: [green]{title}[/green]")

    # ── Summary ──
    console.print()
    console.rule("[bold cyan]Document Summary[/bold cyan]")
    summary = extract_summary(chunks, config)
    if summary:
        console.print(f"Summary: [green]{summary}[/green]")
    else:
        console.print("[yellow]Summary not available[/yellow]")

    # ── Rephrase ──
    console.print()
    console.rule("[bold cyan]Chunk Rephrase[/bold cyan]")
    console.print(f"Model: [green]{config.extraction_model}[/green]")
    rephrase_t0 = time.perf_counter()
    rephrased = rephrase_chunks(chunks, config)
    rephrase_time = time.perf_counter() - rephrase_t0
    console.print(f"Time: [cyan]{rephrase_time:.2f}s[/cyan]")

    # ── Extraction ──
    console.print()
    console.rule("[bold cyan]Extraction[/bold cyan]")
    console.print(f"Model: [green]{config.extraction_model}[/green]")

    t0 = time.perf_counter()
    results = asyncio.run(extract_all(chunks, schema, config, output_path=output))
    wall_time = time.perf_counter() - t0

    # ── Consolidation ──
    console.print()
    console.rule("[bold cyan]Consolidation[/bold cyan]")
    all_results = ([existing_result] + results) if existing_result else results
    consolidated, consolidation_time = consolidate_graphs(
        all_results, config, summarize=True
    )

    # ── Community analysis ──
    console.print()
    console.rule("[bold cyan]Community Analysis[/bold cyan]")
    community_t0 = time.perf_counter()
    consolidated = analyze_communities(consolidated, config)
    community_time = time.perf_counter() - community_t0

    # ── Stats ──
    stats = compute_stats(results, schema.discovery_time, wall_time, consolidation_time)
    stats["community_detection_time"] = round(community_time, 2)
    stats["communities"] = compute_community_stats(consolidated)
    print_stats(stats, schema, consolidated)

    # ── Save results ──
    if existing_data and "schema" in existing_data:
        old_schema = existing_data["schema"]
        entity_types = list(schema.entity_types)
        relation_types = list(schema.relation_types)

        for et in old_schema.get("entity_types", []):
            if et.lower() not in {t.lower() for t in entity_types}:
                entity_types.append(et)
        for rt in old_schema.get("relation_types", []):
            if rt.lower() not in {t.lower() for t in relation_types}:
                relation_types.append(rt)

        old_reasoning = old_schema.get("reasoning", "")
        if old_reasoning and old_reasoning != schema.reasoning:
            reasoning = f"{old_reasoning} | {schema.reasoning}".strip(" |")
        else:
            reasoning = schema.reasoning or old_reasoning
    else:
        entity_types = schema.entity_types
        relation_types = schema.relation_types
        reasoning = schema.reasoning

    stats_entry = dict(stats)
    stats_entry["run"] = {
        "timestamp": time.strftime("%Y-%m-%d %H:%M:%S"),
        "file": str(file_path),
        "backend": "openai" if config.use_openai else "ollama",
        "extraction_model": config.extraction_model,
        "discovery_model": config.discovery_model,
        "max_tokens": config.max_tokens,
        "overlap_tokens": config.overlap_tokens,
        "max_concurrent": config.max_concurrent,
        "use_cache": config.use_cache,
        "no_discovery": no_discovery,
    }

    if existing_data and "stats" in existing_data:
        prior_stats = existing_data.get("stats")
        if isinstance(prior_stats, list):
            stats_list = prior_stats + [stats_entry]
        else:
            stats_list = [prior_stats, stats_entry]
    else:
        stats_list = [stats_entry]

    output_data = {
        "title": title,
        "summary": summary,
        "url": url,
        "language": detected_lang,
        "schema": {
            "entity_types": entity_types,
            "relation_types": relation_types,
            "reasoning": reasoning,
        },
        "stats": stats_list,
        "communities": consolidated.get("communities", []),
        "graph": consolidated,
        "chunks": (existing_data.get("chunks", []) if existing_data else [])
        + [
            {
                "chunk_idx": r.chunk_idx,
                "text": chunks[r.chunk_idx],
                "rephrase": (
                    rephrased[r.chunk_idx] if r.chunk_idx < len(rephrased) else ""
                ),
                "entities": r.entities,
                "relations": r.relations,
                "chunk_time": r.chunk_time,
                "chunk_tokens": r.chunk_tokens,
                "source_file": str(file_path),
            }
            for r in results
        ],
    }
    graph_json_path.write_text(json.dumps(output_data, indent=2))
    console.print(
        f"\n[green]\u2713[/green] Results saved to [cyan]{graph_json_path}[/cyan]"
    )

    # GML export
    if gml_export:
        g = create_network(consolidated)
        nx.write_gml(g, results_dir / "graph.gml")
        console.print(
            f"[green]\u2713[/green] Graph saved to "
            f"[cyan]{results_dir / 'graph.gml'}[/cyan]"
        )

    # HTML export
    if html_report:
        html_path = export_html(
            output_data, results_dir / "index.html", title=results_dir.stem
        )
        console.print(
            f"[green]\u2713[/green] HTML report saved to [cyan]{html_path}[/cyan]"
        )

    # Save console log
    log_path = results_dir / "log.txt"
    console.save_text(str(log_path), clear=False)
    print(f"Console log saved to {log_path}")
    log_path = results_dir / "log.html"
    console.save_html(str(log_path), clear=False)
    print(f"Console log saved to {log_path}")

    # Rename results directory to include the title
    if not output:
        if title and len(title) > 30:
            new_dir_name = "_".join(title.split(" ")[:3])
        else:
            new_dir_name = title.replace(" ", "_")[:30]
        new_dir_path = results_dir.parent / new_dir_name
        if new_dir_path.exists():
            new_dir_path = results_dir.parent / f"{new_dir_name}_{int(time.time())}"
        results_dir.rename(new_dir_path)
        console.print(
            f"[green]\u2713[/green] Results directory renamed to "
            f"[cyan]{new_dir_path}[/cyan]"
        )
