"""CLI entry point for kb-search."""

import click


@click.group()
@click.version_option(package_name="kb-search")
def main():
    """Personal knowledge base with hybrid search."""


@main.command()
@click.option("--model", default=None, help="Embedding model name (HuggingFace).")
@click.option("--status", is_flag=True, help="Show initialisation status.")
def init(model, status):
    """Initialise the knowledge base and download models."""
    from kb_search.config import get_data_dir, get_db_path, load_config
    from kb_search.database import get_connection, get_db_config, init_schema, run_migrations, set_db_config
    from kb_search.embeddings import download_model, get_model_dim

    cfg = load_config()
    data_dir = get_data_dir(cfg)
    db_path = get_db_path(cfg)
    model_name = model or cfg["embedding"]["model"]

    if status:
        click.echo(f"Data directory:  {data_dir} ({'exists' if data_dir.exists() else 'not created'})")
        click.echo(f"Database:        {db_path} ({'exists' if db_path.exists() else 'not created'})")
        if db_path.exists():
            conn = get_connection(db_path)
            db_model = get_db_config(conn, "model_name", "not set")
            db_dim = get_db_config(conn, "embedding_dim", "not set")
            click.echo(f"Model:           {db_model} ({db_dim} dim)")
            conn.close()
        else:
            click.echo(f"Model:           {model_name} (not yet initialised)")
        return

    # Create data directory
    data_dir.mkdir(parents=True, exist_ok=True)

    # Download model and get dimension
    download_model(model_name)
    dim = get_model_dim(model_name)

    # Initialise database
    conn = get_connection(db_path)
    init_schema(conn, embedding_dim=dim)
    run_migrations(conn)
    set_db_config(conn, "model_name", model_name)
    set_db_config(conn, "embedding_dim", str(dim))
    conn.close()

    click.echo(f"Knowledge base initialised at {data_dir}")
    click.echo(f"Model: {model_name} ({dim} dimensions)")
    click.echo("Ready! Add documents with `kb add`.")


@main.command()
@click.argument("path", required=False)
@click.option("--note", default=None, help="Add an inline text note.")
@click.option("--title", default=None, help="Title for the note.")
@click.option("--tags", default=None, help="Comma-separated tags.")
@click.option("--type", "doc_type", default=None, type=click.Choice(["pdf", "markdown", "code", "note"]), help="Force document type.")
@click.option("--language", default=None, type=click.Choice(["python", "bash", "go"]), help="Force code language.")
@click.option("--recursive", is_flag=True, help="Recurse into directories.")
@click.option("--workers", default=None, type=int, help="Number of parallel workers.")
def add(path, note, title, tags, doc_type, language, recursive, workers):
    """Add documents to the knowledge base."""
    import hashlib
    from pathlib import Path as P
    from kb_search.config import get_db_path, load_config
    from kb_search.database import (
        get_connection, hash_exists, insert_chunk, insert_document,
        insert_embedding, tag_document,
    )
    from kb_search.embeddings import check_model_binding, embed_texts
    from kb_search.ingest.detector import detect_type, is_supported
    from kb_search.ingest.note import auto_title, chunk_note

    cfg = load_config()
    db_path = get_db_path(cfg)
    if not db_path.exists():
        raise click.ClickException("Knowledge base not initialised. Run `kb init` first.")

    conn = get_connection(db_path)
    check_model_binding(conn, cfg)
    model_name = cfg["embedding"]["model"]
    tag_list = [t.strip() for t in tags.split(",")] if tags else []

    if note:
        # Inline note
        content_hash = hashlib.sha256(note.encode()).hexdigest()
        if hash_exists(conn, content_hash):
            click.echo("Skipped: note (already indexed)")
            conn.close()
            return
        note_title = title or auto_title(note)
        chunks = chunk_note(note)
        doc_id = insert_document(conn, note_title, None, content_hash, "note")
        for c in chunks:
            chunk_id = insert_chunk(conn, doc_id, c["chunk_index"], c["text"], metadata=c["metadata"])
            emb = embed_texts(model_name, [c["text"]], prefix=cfg["embedding"].get("passage_prefix", ""))
            insert_embedding(conn, chunk_id, emb[0])
        if tag_list:
            tag_document(conn, doc_id, tag_list)
        conn.commit()
        conn.close()
        click.echo(f"Added note: {note_title}")
        return

    if not path:
        raise click.ClickException("Provide a file/directory path or use --note.")

    file_path = P(path).expanduser().resolve()

    if file_path.is_dir():
        _add_directory(conn, file_path, cfg, model_name, tag_list, doc_type, language,
                       recursive, workers)
    elif file_path.is_file():
        result = _add_single_file(conn, file_path, cfg, model_name, tag_list, doc_type, language)
        click.echo(result)
    else:
        raise click.ClickException(f"Path not found: {file_path}")

    conn.close()


def _add_single_file(conn, file_path, cfg, model_name, tag_list, force_type, force_language):
    """Add a single file. Returns a status message."""
    import hashlib
    from kb_search.database import (
        hash_exists, insert_chunk, insert_document, insert_embedding, tag_document,
    )
    from kb_search.embeddings import embed_texts
    from kb_search.ingest.detector import detect_type

    # Dedup check
    content_hash = hashlib.sha256(file_path.read_bytes()).hexdigest()
    if hash_exists(conn, content_hash):
        return f"Skipped: {file_path.name} (already indexed)"

    doc_type, language = detect_type(file_path, force_type, force_language)
    chunks = _get_chunks(file_path, doc_type, language, cfg)

    if not chunks:
        return f"Skipped: {file_path.name} (no content extracted)"

    title = file_path.stem
    doc_id = insert_document(conn, title, str(file_path), content_hash, doc_type,
                             language=language)

    # Embed all chunks in one batch
    texts = [c["text"] for c in chunks]
    prefix = cfg["embedding"].get("passage_prefix", "")
    embeddings = embed_texts(model_name, texts, prefix=prefix)

    for c, emb in zip(chunks, embeddings):
        chunk_id = insert_chunk(conn, doc_id, c["chunk_index"], c["text"],
                                token_count=c.get("token_count"),
                                metadata=c.get("metadata", {}))
        insert_embedding(conn, chunk_id, emb)

    if tag_list:
        tag_document(conn, doc_id, tag_list)

    conn.commit()
    return f"Added: {file_path.name} ({len(chunks)} chunks)"


def _get_chunks(file_path, doc_type, language, cfg):
    """Route to the correct chunking pipeline."""
    if doc_type == "pdf":
        from kb_search.ingest.docling import chunk_document
        return chunk_document(file_path, cfg)
    elif doc_type == "markdown":
        from kb_search.ingest.markdown import chunk_markdown
        text = file_path.read_text(errors="replace")
        return chunk_markdown(text, cfg)
    elif doc_type == "code":
        from kb_search.ingest.code import chunk_code
        text = file_path.read_text(errors="replace")
        return chunk_code(text, language, cfg)
    elif doc_type == "note":
        from kb_search.ingest.note import chunk_note
        text = file_path.read_text(errors="replace")
        return chunk_note(text)
    return []


def _add_directory(conn, dir_path, cfg, model_name, tag_list, force_type, force_language,
                   recursive, workers):
    """Add all supported files in a directory."""
    from pathlib import Path as P
    from kb_search.ingest.detector import is_supported

    pattern = "**/*" if recursive else "*"
    files = sorted(f for f in dir_path.glob(pattern) if f.is_file() and is_supported(f))

    if not files:
        click.echo(f"No supported files found in {dir_path}")
        return

    added = 0
    skipped = 0
    failed = 0
    error_log = cfg.get("data_dir", "~/.kb")
    from kb_search.config import get_data_dir
    error_log_path = get_data_dir(cfg) / "ingest-errors.log"

    with click.progressbar(files, label="Ingesting", show_pos=True) as bar:
        for f in bar:
            try:
                result = _add_single_file(conn, f, cfg, model_name, tag_list,
                                          force_type, force_language)
                if "Skipped" in result:
                    skipped += 1
                else:
                    added += 1
            except Exception as e:
                failed += 1
                with open(error_log_path, "a") as log:
                    log.write(f"{f}: {e}\n")

    click.echo(f"\nAdded {added} documents. {failed} failed. {skipped} skipped (already indexed).")


@main.command()
@click.argument("query")
@click.option("--top", default=None, type=int, help="Number of results.")
@click.option("--tags", default=None, help="Filter by tags (comma-separated).")
@click.option("--type", "doc_type", default=None, type=click.Choice(["pdf", "markdown", "code", "note"]), help="Filter by document type.")
@click.option("--format", "fmt", default=None, type=click.Choice(["json", "human"]), help="Output format.")
@click.option("--fts-only", is_flag=True, help="Full-text search only.")
@click.option("--vec-only", is_flag=True, help="Vector search only.")
@click.option("--threshold", default=None, type=float, help="Minimum score cutoff.")
def search(query, top, tags, doc_type, fmt, fts_only, vec_only, threshold):
    """Search the knowledge base."""
    from kb_search.config import get_db_path, load_config
    from kb_search.database import get_connection, get_db_config
    from kb_search.embeddings import check_model_binding
    from kb_search.search import hybrid_search
    from kb_search.output import format_search_results

    cfg = load_config()
    db_path = get_db_path(cfg)
    if not db_path.exists():
        raise click.ClickException("Knowledge base not initialised. Run `kb init` first.")

    conn = get_connection(db_path)
    check_model_binding(conn, cfg)

    model_name = get_db_config(conn, "model_name") or cfg["embedding"]["model"]
    top = top or cfg["search"]["default_top"]
    fmt = fmt or cfg["search"]["default_format"]
    tag_list = [t.strip() for t in tags.split(",")] if tags else None

    results = hybrid_search(
        conn, query, model_name, cfg,
        top=top, tags=tag_list, doc_type=doc_type,
        fts_only=fts_only, vec_only=vec_only, threshold=threshold,
    )
    conn.close()

    click.echo(format_search_results(results, fmt))


@main.command("list")
@click.option("--type", "doc_type", default=None, type=click.Choice(["pdf", "markdown", "code", "note"]), help="Filter by document type.")
@click.option("--tags", default=None, help="Filter by tags (comma-separated).")
@click.option("--format", "fmt", default=None, type=click.Choice(["json", "human"]), help="Output format.")
def list_docs(doc_type, tags, fmt):
    """List indexed documents."""
    from kb_search.config import get_db_path, load_config
    from kb_search.database import get_connection
    from kb_search.output import format_document_list

    cfg = load_config()
    db_path = get_db_path(cfg)
    if not db_path.exists():
        raise click.ClickException("Knowledge base not initialised. Run `kb init` first.")

    conn = get_connection(db_path)
    fmt = fmt or cfg["search"]["default_format"]

    sql = """
        SELECT d.id, d.title, d.doc_type as type, d.created_at,
               COUNT(c.id) as chunk_count
        FROM documents d
        LEFT JOIN chunks c ON d.id = c.document_id
    """
    joins = []
    where = []
    params = []

    if doc_type:
        where.append("d.doc_type = ?")
        params.append(doc_type)

    tag_list = [t.strip().lower() for t in tags.split(",")] if tags else []
    for i, tag in enumerate(tag_list):
        joins.append(f"JOIN document_tags dt{i} ON d.id = dt{i}.document_id")
        joins.append(f"JOIN tags t{i} ON dt{i}.tag_id = t{i}.id")
        where.append(f"t{i}.name = ?")
        params.append(tag)

    sql += " " + " ".join(joins)
    if where:
        sql += " WHERE " + " AND ".join(where)
    sql += " GROUP BY d.id ORDER BY d.created_at DESC"

    rows = conn.execute(sql, params).fetchall()

    docs = []
    for row in rows:
        tag_rows = conn.execute("""
            SELECT t.name FROM tags t
            JOIN document_tags dt ON t.id = dt.tag_id
            WHERE dt.document_id = ?
            ORDER BY t.name
        """, (row["id"],)).fetchall()
        docs.append({
            "id": row["id"],
            "title": row["title"],
            "type": row["type"],
            "tags": [r["name"] for r in tag_rows],
            "chunk_count": row["chunk_count"],
            "created_at": row["created_at"],
        })

    conn.close()
    click.echo(format_document_list(docs, fmt))


@main.command()
@click.argument("doc_id", type=int)
@click.option("--format", "fmt", default=None, type=click.Choice(["json", "human"]), help="Output format.")
def info(doc_id, fmt):
    """Show document details."""
    import json as jsonlib
    from kb_search.config import get_db_path, load_config
    from kb_search.database import get_connection
    from kb_search.output import format_doc_info

    cfg = load_config()
    db_path = get_db_path(cfg)
    if not db_path.exists():
        raise click.ClickException("Knowledge base not initialised. Run `kb init` first.")

    conn = get_connection(db_path)
    fmt = fmt or cfg["search"]["default_format"]

    row = conn.execute("SELECT * FROM documents WHERE id = ?", (doc_id,)).fetchone()
    if not row:
        raise click.ClickException(f"Document not found: {doc_id}")

    chunks = conn.execute(
        "SELECT chunk_index, text FROM chunks WHERE document_id = ? ORDER BY chunk_index",
        (doc_id,),
    ).fetchall()

    tag_rows = conn.execute("""
        SELECT t.name FROM tags t
        JOIN document_tags dt ON t.id = dt.tag_id
        WHERE dt.document_id = ?
        ORDER BY t.name
    """, (doc_id,)).fetchall()

    info_data = {
        "id": row["id"],
        "title": row["title"],
        "type": row["doc_type"],
        "language": row["language"],
        "path": row["source_path"],
        "content_hash": row["content_hash"],
        "created_at": row["created_at"],
        "tags": [r["name"] for r in tag_rows],
        "chunk_count": len(chunks),
        "chunks": [{"chunk_index": c["chunk_index"], "text": c["text"]} for c in chunks],
    }

    conn.close()
    click.echo(format_doc_info(info_data, fmt))


@main.command()
@click.argument("doc_id", type=int)
@click.option("--yes", is_flag=True, help="Skip confirmation prompt.")
def remove(doc_id, yes):
    """Remove a document from the knowledge base."""
    from kb_search.config import get_db_path, load_config
    from kb_search.database import get_connection

    cfg = load_config()
    db_path = get_db_path(cfg)
    if not db_path.exists():
        raise click.ClickException("Knowledge base not initialised. Run `kb init` first.")

    conn = get_connection(db_path)
    row = conn.execute("SELECT id, title FROM documents WHERE id = ?", (doc_id,)).fetchone()
    if not row:
        raise click.ClickException(f"Document not found: {doc_id}")

    chunk_count = conn.execute(
        "SELECT COUNT(*) FROM chunks WHERE document_id = ?", (doc_id,)
    ).fetchone()[0]

    if not yes:
        if not click.confirm(f"Remove '{row['title']}' and its {chunk_count} chunks?"):
            click.echo("Cancelled.")
            conn.close()
            return

    # Delete vectors for this document's chunks
    conn.execute("""
        DELETE FROM chunks_vec WHERE chunk_id IN (
            SELECT id FROM chunks WHERE document_id = ?
        )
    """, (doc_id,))
    # Cascade handles chunks, document_tags
    conn.execute("DELETE FROM documents WHERE id = ?", (doc_id,))
    conn.commit()
    conn.close()
    click.echo(f"Removed '{row['title']}' ({chunk_count} chunks).")


@main.command("tags")
@click.option("--format", "fmt", default=None, type=click.Choice(["json", "human"]), help="Output format.")
def list_tags(fmt):
    """List all tags with document counts."""
    from kb_search.config import get_db_path, load_config
    from kb_search.database import get_connection
    from kb_search.output import format_tags

    cfg = load_config()
    db_path = get_db_path(cfg)
    if not db_path.exists():
        raise click.ClickException("Knowledge base not initialised. Run `kb init` first.")

    conn = get_connection(db_path)
    fmt = fmt or cfg["search"]["default_format"]

    rows = conn.execute("""
        SELECT t.name, COUNT(dt.document_id) as count
        FROM tags t
        LEFT JOIN document_tags dt ON t.id = dt.tag_id
        GROUP BY t.id
        ORDER BY count DESC, t.name
    """).fetchall()

    tags = [{"name": r["name"], "count": r["count"]} for r in rows]
    conn.close()
    click.echo(format_tags(tags, fmt))


@main.command()
@click.argument("doc_id", type=int)
@click.option("--add", "add_tags", default=None, help="Tags to add (comma-separated).")
@click.option("--remove", "remove_tags", default=None, help="Tags to remove (comma-separated).")
def tag(doc_id, add_tags, remove_tags):
    """Manage tags on a document."""
    from kb_search.config import get_db_path, load_config
    from kb_search.database import get_connection, tag_document, untag_document

    cfg = load_config()
    db_path = get_db_path(cfg)
    if not db_path.exists():
        raise click.ClickException("Knowledge base not initialised. Run `kb init` first.")

    conn = get_connection(db_path)
    row = conn.execute("SELECT id, title FROM documents WHERE id = ?", (doc_id,)).fetchone()
    if not row:
        raise click.ClickException(f"Document not found: {doc_id}")

    if add_tags:
        tags = [t.strip() for t in add_tags.split(",")]
        tag_document(conn, doc_id, tags)
        conn.commit()
        click.echo(f"Added tags [{', '.join(tags)}] to '{row['title']}'")

    if remove_tags:
        tags = [t.strip() for t in remove_tags.split(",")]
        untag_document(conn, doc_id, tags)
        conn.commit()
        click.echo(f"Removed tags [{', '.join(tags)}] from '{row['title']}'")

    conn.close()


@main.command()
@click.option("--format", "fmt", default=None, type=click.Choice(["json", "human"]), help="Output format.")
def status(fmt):
    """Show knowledge base status and statistics."""
    from kb_search.config import get_db_path, load_config
    from kb_search.database import get_connection, get_db_config
    from kb_search.output import format_status

    cfg = load_config()
    db_path = get_db_path(cfg)
    if not db_path.exists():
        raise click.ClickException("Knowledge base not initialised. Run `kb init` first.")

    conn = get_connection(db_path)
    fmt = fmt or cfg["search"]["default_format"]

    doc_counts = {}
    for row in conn.execute("SELECT doc_type, COUNT(*) as cnt FROM documents GROUP BY doc_type").fetchall():
        doc_counts[row["doc_type"]] = row["cnt"]

    total_docs = sum(doc_counts.values())
    total_chunks = conn.execute("SELECT COUNT(*) FROM chunks").fetchone()[0]
    db_size = db_path.stat().st_size

    status_data = {
        "model_name": get_db_config(conn, "model_name", "not set"),
        "embedding_dim": get_db_config(conn, "embedding_dim", "not set"),
        "schema_version": get_db_config(conn, "schema_version", "not set"),
        "db_size_bytes": db_size,
        "documents": doc_counts,
        "total_documents": total_docs,
        "total_chunks": total_chunks,
    }

    conn.close()
    click.echo(format_status(status_data, fmt))


@main.command()
@click.option("--model", default=None, help="Switch to a different embedding model.")
def reindex(model):
    """Re-embed all chunks (optionally with a new model)."""
    import struct
    from kb_search.config import get_db_path, load_config
    from kb_search.database import (
        get_connection, get_db_config, insert_embedding,
        recreate_vec_table, set_db_config,
    )
    from kb_search.embeddings import download_model, embed_texts, get_model_dim

    cfg = load_config()
    db_path = get_db_path(cfg)
    if not db_path.exists():
        raise click.ClickException("Knowledge base not initialised. Run `kb init` first.")

    conn = get_connection(db_path)
    model_name = model or get_db_config(conn, "model_name") or cfg["embedding"]["model"]

    # Download model if switching
    if model:
        download_model(model_name)

    dim = get_model_dim(model_name)

    # Get all chunks
    rows = conn.execute("SELECT id, text FROM chunks ORDER BY id").fetchall()
    if not rows:
        click.echo("No chunks to re-embed.")
        conn.close()
        return

    click.echo(f"Re-embedding {len(rows)} chunks with '{model_name}' ({dim} dim)...")

    # Embed in batches
    batch_size = 256
    all_ids = [r["id"] for r in rows]
    all_texts = [r["text"] for r in rows]

    prefix = cfg["embedding"].get("passage_prefix", "")
    all_embeddings = []
    with click.progressbar(range(0, len(all_texts), batch_size), label="Embedding") as bar:
        for i in bar:
            batch = all_texts[i:i + batch_size]
            batch_embs = embed_texts(model_name, batch, prefix=prefix)
            all_embeddings.extend(batch_embs)

    # Atomically replace vectors
    recreate_vec_table(conn, dim)
    for chunk_id, emb in zip(all_ids, all_embeddings):
        insert_embedding(conn, chunk_id, emb)

    set_db_config(conn, "model_name", model_name)
    set_db_config(conn, "embedding_dim", str(dim))
    conn.commit()
    conn.close()

    click.echo(f"Reindex complete. {len(rows)} chunks embedded with '{model_name}'.")


@main.group(invoke_without_command=True)
@click.pass_context
def config(ctx):
    """View or modify configuration."""
    if ctx.invoked_subcommand is None:
        from kb_search.config import config_with_sources

        entries = config_with_sources()
        max_key = max(len(k) for k, _, _ in entries)
        max_val = max(len(v) for _, v, _ in entries)
        for key, value, source in entries:
            click.echo(f"  {key:<{max_key}}  {value:<{max_val}}  ({source})")


@config.command("set")
@click.argument("key")
@click.argument("value")
def config_set(key, value):
    """Set a configuration value."""
    from kb_search.config import get_config_path, load_config, save_config_value

    cfg = load_config()
    path = get_config_path(cfg)
    save_config_value(path, key, value)
    click.echo(f"Set {key} = {value} in {path}")


main.add_command(config)
