import csv
import json
import time
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Dict, List, Optional
from uuid import uuid4

import click

from synthneura.core.config import get_settings
from synthneura.core.logger import get_logger, set_log_level
from synthneura.ingestion.clinical_trials import fetch_trials
from synthneura.llm.anthropic_client import AnthropicClient
from synthneura.llm.base import LLMClient
from synthneura.llm.local_client import LocalClient, LocalMockClient
from synthneura.llm.openai_client import OpenAIClient
from synthneura.services.extraction import extract_trial_facts
from synthneura.services.pipeline import normalize_trial
from synthneura.services.summary import summarize_trials
from synthneura.storage.base import TrialSink
from synthneura.storage.postgres_sink import PostgresTrialSink
from synthneura.storage.sqlite_sink import SQLiteTrialSink

logger = get_logger(__name__)


def _trial_to_dict(trial: Any) -> Dict[str, Any]:
    if hasattr(trial, "model_dump"):
        return trial.model_dump()
    if hasattr(trial, "dict"):
        return trial.dict()
    return dict(trial)


def _write_json(path: Path, trials: List[Dict[str, Any]]) -> None:
    path.parent.mkdir(parents=True, exist_ok=True)
    with path.open("w", encoding="utf-8") as f:
        json.dump(trials, f, indent=2, ensure_ascii=True)


def _write_json_payload(path: Path, payload: Dict[str, Any]) -> None:
    path.parent.mkdir(parents=True, exist_ok=True)
    with path.open("w", encoding="utf-8") as f:
        json.dump(payload, f, indent=2, ensure_ascii=True)


def _write_csv(path: Path, trials: List[Dict[str, Any]]) -> None:
    path.parent.mkdir(parents=True, exist_ok=True)
    if not trials:
        path.write_text("", encoding="utf-8")
        return
    fieldnames = sorted({key for trial in trials for key in trial.keys()})
    with path.open("w", newline="", encoding="utf-8") as f:
        writer = csv.DictWriter(f, fieldnames=fieldnames)
        writer.writeheader()
        writer.writerows(trials)


def _change_to_dict(change: Any) -> Dict[str, Any]:
    return {
        "nct_id": change.nct_id,
        "change_status": change.change_status,
        "changed_fields": change.changed_fields,
        "before": change.before,
        "after": change.after,
    }


def _safe_json_loads(value: Any, fallback: Any) -> Any:
    if value is None:
        return fallback
    try:
        return json.loads(value)
    except json.JSONDecodeError:
        return fallback


def _item_name(item: Any) -> str:
    if isinstance(item, dict):
        return str(item.get("normalized_name") or item.get("raw_text") or "")
    if hasattr(item, "model_dump"):
        data = item.model_dump()
        return str(data.get("normalized_name") or data.get("raw_text") or "")
    if hasattr(item, "dict"):
        data = item.dict()
        return str(data.get("normalized_name") or data.get("raw_text") or "")
    if item is None:
        return ""
    return str(item)


def _names_from_items(items: List[Any]) -> List[str]:
    names = [_item_name(item) for item in items]
    return [name for name in names if name]


@click.command()
@click.option("--query", required=True, help="Search query for clinical trials.")
@click.option(
    "--db-path",
    default=get_settings().db_path,
    show_default=True,
    help="Path to the SQLite database (sqlite sink only).",
)
@click.option(
    "--db-url",
    default=get_settings().db_url,
    show_default=True,
    help="Database URL for Postgres (postgres sink).",
)
@click.option(
    "--sink",
    default=get_settings().sink,
    show_default=True,
    type=click.Choice(["sqlite", "postgres"], case_sensitive=False),
    help="Storage backend for persisted trials.",
)
@click.option(
    "--log-level",
    default=get_settings().log_level,
    show_default=True,
    type=click.Choice(
        ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"], case_sensitive=False
    ),
    help="Logging verbosity.",
)
@click.option(
    "--max-results",
    default=get_settings().max_results,
    show_default=True,
    type=int,
    help="Maximum number of trials to fetch.",
)
@click.option(
    "--output-path",
    default=get_settings().output_path,
    help="Optional path to write results (JSON or CSV).",
)
@click.option(
    "--output-format",
    default=get_settings().output_format,
    show_default=True,
    type=click.Choice(["json", "csv"], case_sensitive=False),
    help="Output format when --output-path is provided.",
)
@click.option(
    "--summary-path",
    default=get_settings().summary_path,
    help="Optional path to write summary (JSON).",
)
@click.option(
    "--changes-path",
    default=get_settings().changes_path,
    help="Optional path to write change summary (JSON).",
)
@click.option(
    "--since-last-run",
    is_flag=True,
    help="When set, change summary uses diffs since the previous run.",
)
@click.option(
    "--extract",
    is_flag=True,
    help="Run AI-assisted extraction for endpoints and biomarkers.",
)
def run(
    query: str,
    db_path: str,
    db_url: Optional[str],
    sink: str,
    log_level: str,
    max_results: int,
    output_path: Optional[str],
    output_format: str,
    summary_path: Optional[str],
    changes_path: Optional[str],
    since_last_run: bool,
    extract: bool,
) -> None:
    """
    Run the SynthNeura pipeline to fetch, normalize, and store clinical trials.
    """
    # Set runtime log level for this run
    set_log_level(log_level)
    logger.info("Starting SynthNeura CLI run")
    logger.info(
        "query=%s sink=%s log_level=%s",
        query,
        sink.lower(),
        log_level.upper(),
    )

    click.echo(f"Fetching trials for query: {query}")

    try:
        raw_trials = fetch_trials(query, max_results=max_results)

        if not raw_trials:
            logger.warning("No trials returned from fetch_trials for query=%s", query)
            click.echo(
                f"No trials found for the query: {query}."
                f"Try a broader or different query."
            )
            return

        click.echo(f"Fetched {len(raw_trials)} trials.")
        logger.info("Fetched %d raw trials", len(raw_trials))

        stored = 0
        run_id = uuid4().hex
        ingested_at = datetime.now(timezone.utc).isoformat(timespec="seconds")
        run_meta = {
            "run_id": run_id,
            "ingested_at": ingested_at,
            "source": "clinicaltrials.gov",
            "query": query,
        }
        normalized_trials: List[Dict[str, Any]] = []
        trial_models = []
        for raw_trial in raw_trials:
            trial = normalize_trial(raw_trial)
            trial_models.append(trial)
            normalized_trials.append(_trial_to_dict(trial))

            # User-friendly output
            click.echo(
                f"- {trial.nct_id} | "
                f"{trial.status or 'UNKNOWN'} | "
                f"{trial.phase or 'NA'} | "
                f"{(trial.title or '')[:80]}"
            )

        sink_impl: TrialSink
        if sink.lower() == "sqlite":
            sink_impl = SQLiteTrialSink(db_path)
        elif sink.lower() == "postgres":
            if not db_url:
                raise ValueError("SYNTHNEURA_DB_URL is required for postgres sink.")
            sink_impl = PostgresTrialSink(db_url)
        else:
            raise ValueError(f"Unsupported sink: {sink}")

        store_result = sink_impl.store_trials(trial_models, raw_trials, run_meta)
        change_counts = store_result.counts
        stored = len(trial_models)

        logger.info(
            "Completed run. stored=%d new=%d updated=%d unchanged=%d",
            stored,
            change_counts["new"],
            change_counts["updated"],
            change_counts["unchanged"],
        )
        click.echo("Pipeline completed successfully.")

        summary = summarize_trials(
            normalized_trials, run_meta=run_meta, change_counts=change_counts
        )
        summary_dict = _trial_to_dict(summary)

        click.echo(
            f"Summary: total={summary.total_trials} "
            f"status_count={len(summary.status_counts)} "
            f"phase_count={len(summary.phase_counts)} "
            f"new={change_counts['new']} "
            f"updated={change_counts['updated']} "
            f"unchanged={change_counts['unchanged']}"
        )

        if output_path:
            path = Path(output_path)
            if output_format.lower() == "json":
                _write_json_payload(
                    path, {"summary": summary_dict, "trials": normalized_trials}
                )
            else:
                _write_csv(path, normalized_trials)
            logger.info("Wrote output %s to %s", output_format.lower(), path)
            click.echo(f"Wrote {output_format.lower()} output to {path}")

        if summary_path:
            summary_file = Path(summary_path)
        elif output_path and output_format.lower() == "csv":
            summary_file = Path(f"{output_path}.summary.json")
        else:
            summary_file = None

        if summary_file:
            _write_json_payload(summary_file, summary_dict)
            logger.info("Wrote summary to %s", summary_file)
            click.echo(f"Wrote summary to {summary_file}")

        if extract:
            settings = get_settings()
            provider = settings.llm_provider.lower()
            model = None
            client: Optional[LLMClient] = None
            if provider == "openai":
                if not settings.openai_api_key:
                    raise ValueError(
                        "SYNTHNEURA_OPENAI_API_KEY is required for extraction."
                    )
                client = OpenAIClient(
                    api_key=settings.openai_api_key,
                    model=settings.openai_model,
                    retries=settings.llm_retries,
                    backoff_seconds=settings.llm_backoff_seconds,
                )
                model = settings.openai_model
            elif provider == "anthropic":
                client = AnthropicClient()
            elif provider == "local":
                client = LocalClient()
            elif provider == "local-mock":
                client = LocalMockClient()
            else:
                raise ValueError(f"Unsupported LLM provider: {settings.llm_provider}")
            if client is None:
                raise ValueError(f"Unsupported LLM provider: {settings.llm_provider}")

            extracted = 0
            failed = 0
            for trial in trial_models:
                try:
                    facts = extract_trial_facts(trial, client)
                except Exception as e:
                    failed += 1
                    logger.warning(
                        "Extraction failed nct_id=%s error=%s", trial.nct_id, e
                    )
                    sink_impl.store_extraction_failure(
                        nct_id=trial.nct_id,
                        run_id=run_id,
                        ingested_at=ingested_at,
                        provider=provider,
                        model=model,
                        error=str(e),
                    )
                    continue
                sink_impl.store_extractions(
                    nct_id=trial.nct_id,
                    endpoints=facts.endpoints,
                    biomarkers=facts.biomarkers,
                    run_id=run_id,
                    ingested_at=ingested_at,
                    provider=facts.provider,
                    model=facts.model,
                    prompt_version=facts.prompt_version,
                    raw_response=facts.raw_response,
                    validation_status=facts.validation_status,
                    validation_errors=facts.validation_errors,
                    confidence=facts.confidence,
                )
                previous = sink_impl.fetch_previous_extraction(trial.nct_id, run_id)
                if previous:
                    prev_endpoints = _names_from_items(
                        _safe_json_loads(previous.get("endpoints_json"), [])
                    )
                    prev_biomarkers = _names_from_items(
                        _safe_json_loads(previous.get("biomarkers_json"), [])
                    )
                else:
                    prev_endpoints = []
                    prev_biomarkers = []
                current_endpoints = _names_from_items(facts.endpoints)
                current_biomarkers = _names_from_items(facts.biomarkers)
                endpoints_added = sorted(set(current_endpoints) - set(prev_endpoints))
                endpoints_removed = sorted(set(prev_endpoints) - set(current_endpoints))
                biomarkers_added = sorted(
                    set(current_biomarkers) - set(prev_biomarkers)
                )
                biomarkers_removed = sorted(
                    set(prev_biomarkers) - set(current_biomarkers)
                )
                if any(
                    [
                        endpoints_added,
                        endpoints_removed,
                        biomarkers_added,
                        biomarkers_removed,
                    ]
                ):
                    sink_impl.store_extraction_diff(
                        nct_id=trial.nct_id,
                        run_id=run_id,
                        ingested_at=ingested_at,
                        endpoints_added=endpoints_added,
                        endpoints_removed=endpoints_removed,
                        biomarkers_added=biomarkers_added,
                        biomarkers_removed=biomarkers_removed,
                        prompt_version=facts.prompt_version,
                    )
                extracted += 1
                if settings.llm_request_delay_seconds > 0:
                    time.sleep(settings.llm_request_delay_seconds)
            logger.info(
                "Extraction completed extracted=%d failed=%d", extracted, failed
            )
            click.echo(f"Extraction completed: extracted={extracted} failed={failed}")
        else:
            extracted = None
            failed = None

        if since_last_run:
            previous_time = sink_impl.fetch_previous_run_time(run_id)
            change_records = [
                _change_to_dict(change)
                for change in sink_impl.fetch_diffs_between(
                    previous_time, run_meta["ingested_at"]
                )
            ]
        else:
            change_records = [
                _change_to_dict(change) for change in store_result.changes
            ]
        change_records.sort(
            key=lambda record: len(record.get("changed_fields", [])), reverse=True
        )
        changes_payload = {
            "run": run_meta,
            "changes": change_records,
            "top_changed": change_records[:5],
        }
        if extracted is not None and failed is not None:
            changes_payload["extraction"] = {
                "provider": get_settings().llm_provider,
                "model": get_settings().openai_model,
                "extracted": extracted,
                "failed": failed,
            }

        if changes_path:
            changes_file = Path(changes_path)
        elif output_path:
            changes_file = Path(f"{output_path}.changes.json")
        else:
            changes_file = None

        if changes_file:
            _write_json_payload(changes_file, changes_payload)
            logger.info("Wrote change summary to %s", changes_file)
            click.echo(f"Wrote change summary to {changes_file}")

    except ValueError as e:
        logger.warning("ValueError during run: %s", e)
        click.echo(f"Error: {e}")
    except Exception as e:
        logger.exception("Unexpected error during run")
        click.echo(f"An unexpected error occurred: {e}")


if __name__ == "__main__":
    run()
