"""Recommendation worker - generates explainable trade recommendations from trend data.

Fetches the latest trend summaries for a ticker, evaluates eligibility
using deterministic rules, builds Recommendation objects with thesis
and evidence citations, and persists them to PostgreSQL.

Requirements: 7.1, 7.2, 7.3, 7.4
"""
from __future__ import annotations

import json
import logging
import uuid as _uuid
from datetime import datetime, timezone

import asyncpg
from minio import Minio

from services.aggregation.projection import TrendProjection
from services.lake_publisher.worker import publish_recommendation_facts
from services.recommendation.eligibility import (
    EligibilityConfig,
    EligibilityResult,
    evaluate_eligibility,
)
from services.recommendation.suppression import (
    DataQualityContext,
    SuppressionConfig,
    SuppressionResult,
    evaluate_suppression,
)
from services.recommendation.thesis_llm import (
    THESIS_PROMPT_VERSION,
    rewrite_thesis_with_llm,
)
from services.shared.config import OllamaConfig
from services.shared.metrics import (
    RECOMMENDATION_CONFIDENCE,
    RECOMMENDATION_GENERATED,
    RECOMMENDATION_SUPPRESSED,
)
from services.shared.schemas import (
    ModelMetadata,
    PositionSizing,
    Recommendation,
    RecommendationMode,
    TrendDirection,
    TrendSummary,
    TrendWindow,
)
from services.validation.prediction_snapshot import create_prediction_snapshot

logger = logging.getLogger(__name__)

# ---------------------------------------------------------------------------
# Fetch latest trend summary for a ticker + window
# ---------------------------------------------------------------------------

# ---------------------------------------------------------------------------
# Fetch data quality context for suppression checks
# ---------------------------------------------------------------------------

_DATA_QUALITY_QUERY = """
WITH latest_trend AS (
    SELECT top_supporting_evidence, top_opposing_evidence
    FROM trend_windows
    WHERE entity_id = $1 AND "window" = $2
    ORDER BY generated_at DESC
    LIMIT 1
),
evidence_ids AS (
    SELECT jsonb_array_elements_text(
        COALESCE(lt.top_supporting_evidence, '[]'::jsonb)
        || COALESCE(lt.top_opposing_evidence, '[]'::jsonb)
    ) AS eid
    FROM latest_trend lt
)
SELECT
    COUNT(*) AS total_documents,
    COUNT(*) FILTER (WHERE di.validation_status = 'valid') AS valid_documents,
    COUNT(*) FILTER (WHERE di.validation_status = 'failed') AS failed_documents,
    AVG(di.confidence) FILTER (WHERE di.validation_status = 'valid') AS avg_extraction_confidence,
    MAX(d.published_at) AS newest_evidence_at,
    ARRAY_AGG(DISTINCT d.source_type) FILTER (WHERE d.source_type IS NOT NULL) AS source_types
FROM documents d
JOIN document_intelligence di ON di.document_id = d.id
WHERE d.id::text IN (SELECT eid FROM evidence_ids)
"""


async def fetch_data_quality_context(
    pool: asyncpg.Pool,
    ticker: str,
    window: str,
) -> DataQualityContext | None:
    """Fetch data quality metrics for the documents underlying a trend.

    Returns None if the query fails or returns no data, in which case
    the suppression module will fall back to summary-based estimation.
    """
    try:
        row = await pool.fetchrow(_DATA_QUALITY_QUERY, ticker, window)
        if row is None or row["total_documents"] == 0:
            return None

        source_types_raw = row["source_types"]
        source_types = set(source_types_raw) if source_types_raw else set()

        return DataQualityContext(
            total_documents=int(row["total_documents"]),
            valid_documents=int(row["valid_documents"] or 0),
            failed_documents=int(row["failed_documents"] or 0),
            avg_extraction_confidence=float(row["avg_extraction_confidence"] or 0.0),
            newest_evidence_at=row["newest_evidence_at"],
            source_types=source_types,
        )
    except Exception:
        logger.warning(
            "Failed to fetch data quality context for %s/%s — will use summary fallback",
            ticker, window, exc_info=True,
        )
        return None


_LATEST_TREND_QUERY = """
SELECT
    entity_type, entity_id, "window", trend_direction, trend_strength,
    confidence, top_supporting_evidence, top_opposing_evidence,
    dominant_catalysts, material_risks, contradiction_score,
    disagreement_details, market_context, generated_at
FROM trend_windows
WHERE entity_id = $1 AND "window" = $2
ORDER BY generated_at DESC
LIMIT 1
"""


def _parse_trend_row(row: asyncpg.Record) -> TrendSummary:
    """Convert a trend_windows row into a TrendSummary."""
    supporting = row["top_supporting_evidence"]
    if isinstance(supporting, str):
        supporting = json.loads(supporting)

    opposing = row["top_opposing_evidence"]
    if isinstance(opposing, str):
        opposing = json.loads(opposing)

    catalysts = row["dominant_catalysts"]
    if isinstance(catalysts, str):
        catalysts = json.loads(catalysts)

    risks = row["material_risks"]
    if isinstance(risks, str):
        risks = json.loads(risks)

    return TrendSummary(
        entity_type=row["entity_type"],
        entity_id=row["entity_id"],
        window=TrendWindow(row["window"]),
        trend_direction=TrendDirection(row["trend_direction"]),
        trend_strength=float(row["trend_strength"]),
        confidence=float(row["confidence"]),
        top_supporting_evidence=supporting or [],
        top_opposing_evidence=opposing or [],
        dominant_catalysts=catalysts or [],
        material_risks=risks or [],
        contradiction_score=float(row["contradiction_score"] or 0.0),
        generated_at=row["generated_at"],
    )


async def fetch_latest_trend(
    pool: asyncpg.Pool,
    ticker: str,
    window: str,
) -> TrendSummary | None:
    """Fetch the most recent trend summary for a ticker and window."""
    row = await pool.fetchrow(_LATEST_TREND_QUERY, ticker, window)
    if row is None:
        return None
    return _parse_trend_row(row)


# ---------------------------------------------------------------------------
# Fetch latest trend projection for a ticker + window
# ---------------------------------------------------------------------------

_LATEST_PROJECTION_QUERY = """
SELECT
    tp.projected_direction, tp.projected_strength, tp.projected_confidence,
    tp.projection_horizon, tp.driving_factors, tp.macro_contribution_pct,
    tp.diverges_from_current, tp.computed_at
FROM trend_projections tp
JOIN trend_windows tw ON tw.id = tp.trend_window_id
WHERE tw.entity_id = $1 AND tw."window" = $2
ORDER BY tp.computed_at DESC
LIMIT 1
"""


async def fetch_latest_projection(
    pool: asyncpg.Pool,
    ticker: str,
    window: str,
) -> TrendProjection | None:
    """Fetch the most recent trend projection for a ticker and window.

    Returns None if no projection exists. Low-confidence projections
    are returned with low_confidence=True so callers can decide whether
    to use them (Requirement 12.9).
    """
    try:
        row = await pool.fetchrow(_LATEST_PROJECTION_QUERY, ticker, window)
        if row is None:
            return None

        driving_factors = row["driving_factors"]
        if isinstance(driving_factors, str):
            driving_factors = json.loads(driving_factors)

        proj = TrendProjection(
            projected_direction=row["projected_direction"],
            projected_strength=float(row["projected_strength"]),
            projected_confidence=float(row["projected_confidence"]),
            projection_horizon=row["projection_horizon"],
            driving_factors=driving_factors or [],
            macro_contribution_pct=float(row["macro_contribution_pct"] or 0.0),
            diverges_from_current=bool(row["diverges_from_current"]),
            computed_at=row["computed_at"],
            low_confidence=float(row["projected_confidence"]) < 0.3,
        )
        return proj
    except Exception:
        logger.warning(
            "Failed to fetch projection for %s/%s — continuing without projection",
            ticker, window, exc_info=True,
        )
        return None


# ---------------------------------------------------------------------------
# Build thesis from trend summary (deterministic, no LLM)
# ---------------------------------------------------------------------------


def build_thesis(
    summary: TrendSummary,
    result: EligibilityResult,
    projection: TrendProjection | None = None,
) -> str:
    """Generate a deterministic thesis string from trend data.

    This is the descriptive analysis portion (Requirement 7.2).
    The LLM wording layer is a separate optional task.

    When a TrendProjection is provided and is not low-confidence,
    the thesis incorporates the projected direction and key driving
    factors (Requirement 12.8).
    """
    direction = summary.trend_direction.value
    ticker = summary.entity_id
    window = summary.window.value
    strength = summary.trend_strength
    confidence = summary.confidence

    parts: list[str] = []

    # Opening: direction and strength
    parts.append(
        f"{ticker} shows a {direction} trend over the {window} window "
        + f"with strength {strength:.2f} and confidence {confidence:.2f}."
    )

    # Catalysts
    if summary.dominant_catalysts:
        catalyst_str = ", ".join(summary.dominant_catalysts[:3])
        parts.append(f"Dominant catalysts: {catalyst_str}.")

    # Contradiction note (Requirement 7.2 — separate descriptive from prescriptive)
    if summary.contradiction_score > 0.15:
        parts.append(
            "Notable signal disagreement detected "
            + f"(contradiction score: {summary.contradiction_score:.2f})."
        )

    # Trend projection (Requirement 12.8)
    if projection is not None and not projection.low_confidence:
        proj_dir = projection.projected_direction
        proj_str = projection.projected_strength
        parts.append(
            f"Forward projection ({projection.projection_horizon}): "
            f"{proj_dir} at strength {proj_str:.2f}."
        )
        # Include top driving factors
        non_divergence_factors = [
            f for f in projection.driving_factors
            if not f.startswith("DIVERGENCE:")
        ]
        if non_divergence_factors:
            factors_str = "; ".join(non_divergence_factors[:2])
            parts.append(f"Key drivers: {factors_str}.")
        if projection.diverges_from_current:
            parts.append(
                f"Note: projection diverges from current {direction} trend."
            )

    # Risks
    if summary.material_risks:
        risk_str = "; ".join(summary.material_risks[:2])
        parts.append(f"Key risks: {risk_str}.")

    # Evidence count
    supporting_count = len(summary.top_supporting_evidence)
    opposing_count = len(summary.top_opposing_evidence)
    parts.append(
        f"Based on {supporting_count} supporting and "
        + f"{opposing_count} opposing evidence documents."
    )

    # Prescriptive action (separated per Requirement 7.2)
    action = result.action.value.upper()
    mode = result.mode.value.replace("_", " ")
    parts.append(f"Recommendation: {action} ({mode}).")

    return " ".join(parts)


# ---------------------------------------------------------------------------
# Build risk classification (Requirement 7.2)
# ---------------------------------------------------------------------------


def classify_risk(
    summary: TrendSummary,
    result: EligibilityResult,
) -> str:
    """Assign a risk classification label based on signal quality.

    Returns one of: low, moderate, high, very_high.
    """
    score = 0.0

    # Contradiction raises risk
    score += summary.contradiction_score * 2.0

    # Low confidence raises risk
    score += (1.0 - summary.confidence) * 1.5

    # Low evidence count raises risk
    evidence_count = len(summary.top_supporting_evidence) + len(summary.top_opposing_evidence)
    if evidence_count < 3:
        score += 1.0
    elif evidence_count < 5:
        score += 0.5

    # Rejection reasons raise risk
    score += len(result.rejection_reasons) * 0.5

    if score >= 3.0:
        return "very_high"
    if score >= 2.0:
        return "high"
    if score >= 1.0:
        return "moderate"
    return "low"


# ---------------------------------------------------------------------------
# Build Recommendation from eligibility result
# ---------------------------------------------------------------------------


def build_recommendation(
    summary: TrendSummary,
    result: EligibilityResult,
    reference_time: datetime | None = None,
    llm_thesis: str | None = None,
    suppression_result: SuppressionResult | None = None,
    projection: TrendProjection | None = None,
) -> Recommendation:
    """Assemble a Recommendation object from a trend summary and eligibility result.

    Combines all evidence refs (supporting + opposing) into the recommendation
    so the full decision trace is available (Requirement 8.3).

    If ``llm_thesis`` is provided (from the optional LLM wording layer),
    it replaces the deterministic thesis text while preserving the risk
    classification prefix.

    If ``suppression_result`` indicates suppression, a suppression note
    is appended to the thesis for audit visibility (Requirement 7.4).

    If ``projection`` is provided and is not low-confidence, the thesis
    incorporates projected direction and driving factors (Requirement 12.8).
    The time_horizon may be refined based on the projection horizon.
    """
    if reference_time is None:
        reference_time = datetime.now(timezone.utc)

    # Combine evidence refs — supporting first, then opposing
    evidence_refs = list(summary.top_supporting_evidence) + list(summary.top_opposing_evidence)

    deterministic_thesis = build_thesis(summary, result, projection=projection)
    risk_class = classify_risk(summary, result)

    # Use LLM-rewritten thesis if available, otherwise deterministic
    thesis_body = llm_thesis if llm_thesis else deterministic_thesis

    # Append suppression note if suppressed (Requirement 7.4)
    if suppression_result and suppression_result.suppressed:
        reason_strs = [r.value for r in suppression_result.reasons]
        thesis_body += (
            f" [SUPPRESSED: data quality below threshold "
            f"(score={suppression_result.data_quality_score:.2f}, "
            f"reasons={', '.join(reason_strs)})]"
        )

    # Determine time_horizon — refine with projection horizon if available
    # (Requirement 12.8)
    time_horizon = result.time_horizon
    if projection is not None and not projection.low_confidence:
        # Append projection horizon context to time_horizon
        time_horizon = f"{result.time_horizon} (proj:{projection.projection_horizon})"

    # Track whether the thesis was LLM-generated for audit
    if llm_thesis:
        provider = "ollama"
        model_name = "thesis-rewrite"
        prompt_version = THESIS_PROMPT_VERSION
    else:
        provider = "deterministic"
        model_name = "eligibility-v1"
        prompt_version = ""

    return Recommendation(
        ticker=summary.entity_id,
        action=result.action,
        mode=result.mode,
        confidence=summary.confidence,
        time_horizon=time_horizon,
        thesis=f"[risk:{risk_class}] {thesis_body}",
        invalidation_conditions=result.invalidation_conditions,
        position_sizing=PositionSizing(
            portfolio_pct=result.position_sizing.portfolio_pct,
            max_loss_pct=result.position_sizing.max_loss_pct,
        ),
        evidence_refs=evidence_refs,
        model_metadata=ModelMetadata(
            provider=provider,
            model_name=model_name,
            prompt_version=prompt_version,
            schema_version="1.0.0",
        ),
        generated_at=reference_time,
    )


# ---------------------------------------------------------------------------
# Persist recommendation to PostgreSQL
# ---------------------------------------------------------------------------

_INSERT_RECOMMENDATION = """
INSERT INTO recommendations (
    ticker, action, mode, confidence, time_horizon,
    thesis, invalidation_conditions, portfolio_pct, max_loss_pct,
    model_version, model_provider, prompt_version, schema_version,
    risk_classification, generated_at
) VALUES (
    $1, $2, $3, $4, $5,
    $6, $7::jsonb, $8, $9,
    $10, $11, $12, $13,
    $14, $15
)
RETURNING id
"""

_INSERT_REC_EVIDENCE = """
INSERT INTO recommendation_evidence (
    recommendation_id, document_id, evidence_type, weight
) VALUES ($1, $2::uuid, $3, $4)
"""

_INSERT_RISK_EVALUATION = """
INSERT INTO risk_evaluations (
    recommendation_id, eligible, allowed_mode, rejection_reasons, risk_checks, evaluated_at
) VALUES ($1::uuid, $2, $3, $4::jsonb, $5::jsonb, $6)
"""

_FETCH_RECOMMENDATION = """
SELECT
    id, ticker, action, mode, confidence, time_horizon,
    thesis, invalidation_conditions, portfolio_pct, max_loss_pct,
    model_version, model_provider, prompt_version, schema_version,
    risk_classification, generated_at
FROM recommendations
WHERE id = $1::uuid
"""

_FETCH_REC_EVIDENCE = """
SELECT document_id, evidence_type, weight
FROM recommendation_evidence
WHERE recommendation_id = $1::uuid
ORDER BY evidence_type, weight DESC
"""

_FETCH_LATEST_RECS_FOR_TICKER = """
SELECT
    id, ticker, action, mode, confidence, time_horizon,
    thesis, invalidation_conditions, portfolio_pct, max_loss_pct,
    model_version, model_provider, prompt_version, schema_version,
    risk_classification, generated_at
FROM recommendations
WHERE ticker = $1
ORDER BY generated_at DESC
LIMIT $2
"""


def _extract_risk_classification(thesis: str) -> str:
    """Extract the risk classification from the thesis prefix."""
    if thesis.startswith("[risk:"):
        end = thesis.find("]")
        if end > 6:
            return thesis[6:end]
    return "moderate"


async def persist_recommendation(
    pool: asyncpg.Pool,
    rec: Recommendation,
    supporting_ids: list[str],
    opposing_ids: list[str],
    eligibility_result: EligibilityResult | None = None,
) -> str:
    """Insert a recommendation, evidence citations, and risk evaluation.

    Persists the full model metadata and risk classification for audit
    trail (Requirement 8.3). Also writes the eligibility decision to
    the risk_evaluations table when provided.

    Returns the recommendation UUID.
    """
    risk_class = _extract_risk_classification(rec.thesis)

    row = await pool.fetchrow(
        _INSERT_RECOMMENDATION,
        rec.ticker,
        rec.action.value,
        rec.mode.value,
        rec.confidence,
        rec.time_horizon,
        rec.thesis,
        json.dumps(rec.invalidation_conditions),
        rec.position_sizing.portfolio_pct,
        rec.position_sizing.max_loss_pct,
        rec.model_metadata.model_name,
        rec.model_metadata.provider,
        rec.model_metadata.prompt_version,
        rec.model_metadata.schema_version,
        risk_class,
        rec.generated_at,
    )
    rec_id = str(row["id"])

    # Insert evidence citations with position-based weighting.
    # Filter out non-UUID document IDs (e.g. synthetic "pattern:..." IDs from
    # competitive signal propagation) — the recommendation_evidence table
    # requires a valid UUID foreign key to the documents table.
    evidence_rows: list[tuple[str, str, str, float]] = []
    for idx, doc_id in enumerate(supporting_ids):
        try:
            _uuid.UUID(doc_id)
        except (ValueError, AttributeError):
            continue
        weight = round(1.0 / (1.0 + idx * 0.1), 4)  # rank decay
        evidence_rows.append((rec_id, doc_id, "supporting", weight))
    for idx, doc_id in enumerate(opposing_ids):
        try:
            _uuid.UUID(doc_id)
        except (ValueError, AttributeError):
            continue
        weight = round(1.0 / (1.0 + idx * 0.1), 4)
        evidence_rows.append((rec_id, doc_id, "opposing", weight))

    if evidence_rows:
        try:
            await pool.executemany(_INSERT_REC_EVIDENCE, evidence_rows)
        except Exception:
            logger.warning(
                "Failed to insert %d evidence rows for recommendation %s",
                len(evidence_rows),
                rec_id,
                exc_info=True,
            )

    # Persist the eligibility/risk evaluation for audit trail
    if eligibility_result is not None:
        rejection_reasons_json = json.dumps(
            [r.value for r in eligibility_result.rejection_reasons]
        )
        risk_checks = {
            "time_horizon": eligibility_result.time_horizon,
            "position_sizing": {
                "portfolio_pct": eligibility_result.position_sizing.portfolio_pct,
                "max_loss_pct": eligibility_result.position_sizing.max_loss_pct,
            },
            "invalidation_conditions": eligibility_result.invalidation_conditions,
            "risk_classification": risk_class,
        }

        # Store probabilistic EV fields in risk_checks JSONB (Req 16.2)
        if eligibility_result.pipeline_mode == "probabilistic":
            risk_checks["ev"] = eligibility_result.ev_value
            risk_checks["p_bull"] = eligibility_result.p_bull
            risk_checks["pipeline_mode"] = eligibility_result.pipeline_mode
            risk_checks["ev_threshold"] = 0.005
        await pool.execute(
            _INSERT_RISK_EVALUATION,
            rec_id,
            eligibility_result.eligible,
            eligibility_result.mode.value,
            rejection_reasons_json,
            json.dumps(risk_checks),
            rec.generated_at,
        )

    return rec_id


async def fetch_recommendation_by_id(
    pool: asyncpg.Pool,
    recommendation_id: str,
) -> dict[str, object] | None:
    """Fetch a persisted recommendation with its evidence citations.

    Returns a dict with the recommendation fields and an 'evidence' list,
    or None if not found.
    """
    row = await pool.fetchrow(_FETCH_RECOMMENDATION, recommendation_id)
    if row is None:
        return None

    rec_dict = dict(row)
    # Parse JSONB fields
    if isinstance(rec_dict.get("invalidation_conditions"), str):
        rec_dict["invalidation_conditions"] = json.loads(rec_dict["invalidation_conditions"])

    # Fetch evidence
    evidence_rows = await pool.fetch(_FETCH_REC_EVIDENCE, recommendation_id)
    rec_dict["evidence"] = [
        {
            "document_id": str(e["document_id"]),
            "evidence_type": e["evidence_type"],
            "weight": float(e["weight"]),
        }
        for e in evidence_rows
    ]

    return rec_dict


async def fetch_latest_recommendations(
    pool: asyncpg.Pool,
    ticker: str,
    limit: int = 10,
) -> list[dict[str, object]]:
    """Fetch the most recent recommendations for a ticker.

    Returns a list of recommendation dicts (without evidence — use
    fetch_recommendation_by_id for full detail).
    """
    rows = await pool.fetch(_FETCH_LATEST_RECS_FOR_TICKER, ticker, limit)
    results = []
    for row in rows:
        rec_dict = dict(row)
        if isinstance(rec_dict.get("invalidation_conditions"), str):
            rec_dict["invalidation_conditions"] = json.loads(rec_dict["invalidation_conditions"])
        results.append(rec_dict)
    return results


# ---------------------------------------------------------------------------
# Main entry point: generate recommendation for a ticker
# ---------------------------------------------------------------------------


_DEDUP_CHECK_QUERY = """
SELECT r.id, r.confidence, r.action, r.mode, r.generated_at
FROM recommendations r
WHERE r.ticker = $1
  AND r.time_horizon LIKE $2 || '%'
ORDER BY r.generated_at DESC
LIMIT 1
"""


async def _is_duplicate_recommendation(
    pool: asyncpg.Pool,
    ticker: str,
    summary: TrendSummary,
    result: EligibilityResult,
) -> bool:
    """Check if the latest recommendation for this ticker+window is effectively identical.

    Compares confidence, action, and mode. If they match, the underlying
    trend data hasn't changed meaningfully and we skip regeneration.
    """
    horizon_prefix = _map_time_horizon_prefix(summary.window.value)
    row = await pool.fetchrow(_DEDUP_CHECK_QUERY, ticker, horizon_prefix)
    if row is None:
        return False

    # If the previous recommendation has the same action, mode, and confidence
    # (within a small tolerance), it's a duplicate
    prev_confidence = float(row["confidence"])
    prev_action = row["action"]
    prev_mode = row["mode"]

    same_action = prev_action == result.action.value
    same_mode = prev_mode == result.mode.value
    same_confidence = abs(prev_confidence - summary.confidence) < 0.01

    if same_action and same_mode and same_confidence:
        logger.info(
            "Skipping duplicate recommendation for %s — action=%s mode=%s "
            "confidence=%.3f matches previous",
            ticker, prev_action, prev_mode, prev_confidence,
        )
        return True
    return False


def _map_time_horizon_prefix(window: str) -> str:
    """Map window to the time_horizon prefix for dedup matching."""
    mapping = {
        "intraday": "intraday",
        "1d": "swing_1d",
        "7d": "swing_1d",
        "30d": "position_10d",
        "90d": "position_30d",
    }
    return mapping.get(window, "window_")


# ---------------------------------------------------------------------------
# Fetch evidence signals and docs for prediction snapshot (Requirement 1.1)
# ---------------------------------------------------------------------------

_EVIDENCE_SIGNALS_QUERY = """
SELECT
    di.document_id::text AS document_id,
    di.id::text AS signal_id,
    dir.ticker,
    d.source_type AS source,
    d.source_type,
    dir.catalyst_type,
    dir.sentiment,
    dir.impact_score AS impact,
    di.confidence AS extraction_confidence,
    di.source_credibility AS weight
FROM document_impact_records dir
JOIN document_intelligence di ON di.id = dir.intelligence_id
JOIN documents d ON d.id = di.document_id
WHERE di.document_id = ANY($1::uuid[])
  AND di.validation_status = 'valid'
"""

_EVIDENCE_DOCS_QUERY = """
SELECT
    d.id::text AS document_id,
    COALESCE(d.title, '') AS title,
    COALESCE(d.url, '') AS url
FROM documents d
WHERE d.id = ANY($1::uuid[])
"""


async def _fetch_evidence_for_snapshot(
    pool: asyncpg.Pool,
    document_ids: list[str],
) -> tuple[list[dict], list[dict]]:
    """Fetch evidence signals and document metadata for prediction snapshot.

    Filters out non-UUID document IDs (e.g. synthetic pattern IDs) since
    they cannot be looked up in the documents table.

    Returns (evidence_signals, evidence_docs).
    """
    # Filter to valid UUIDs only
    valid_ids: list[str] = []
    for doc_id in document_ids:
        try:
            _uuid.UUID(doc_id)
            valid_ids.append(doc_id)
        except (ValueError, AttributeError):
            continue

    if not valid_ids:
        return [], []

    signal_rows = await pool.fetch(_EVIDENCE_SIGNALS_QUERY, valid_ids)
    evidence_signals = [
        {
            "document_id": row["document_id"],
            "signal_id": row["signal_id"],
            "ticker": row["ticker"] or "",
            "source": row["source"] or "",
            "source_type": row["source_type"] or "",
            "catalyst_type": row["catalyst_type"] or "",
            "sentiment": row["sentiment"] or "",
            "impact": float(row["impact"] or 0.0),
            "extraction_confidence": float(row["extraction_confidence"] or 0.0),
            "weight": float(row["weight"] or 0.0),
        }
        for row in signal_rows
    ]

    doc_rows = await pool.fetch(_EVIDENCE_DOCS_QUERY, valid_ids)
    evidence_docs = [
        {
            "document_id": row["document_id"],
            "title": row["title"],
            "url": row["url"],
        }
        for row in doc_rows
    ]

    return evidence_signals, evidence_docs


async def generate_recommendation(
    pool: asyncpg.Pool,
    ticker: str,
    window: str = TrendWindow.SEVEN_DAY.value,
    config: EligibilityConfig | None = None,
    reference_time: datetime | None = None,
    ollama_config: OllamaConfig | None = None,
    suppression_config: SuppressionConfig | None = None,
    minio_client: Minio | None = None,
) -> Recommendation | None:
    """Generate and persist a recommendation for a ticker from its latest trend.

    Steps:
    1. Fetch the latest trend summary for the ticker + window.
    1b. Skip if the latest recommendation for this ticker is effectively identical.
    2. Fetch the latest trend projection (Requirement 12.8, 12.9).
    3. Evaluate data quality suppression (Requirement 7.4).
    4. Evaluate eligibility using deterministic rules.
    5. Build a Recommendation object with thesis and evidence.
       - If ``ollama_config`` is provided, the deterministic thesis is
         rewritten into analyst-quality prose via the LLM wording layer.
    6. Persist the recommendation and evidence citations.

    Returns the Recommendation, or None if no trend data exists or recommendation
    is a duplicate of the most recent one.
    """
    if reference_time is None:
        reference_time = datetime.now(timezone.utc)

    cfg = config or EligibilityConfig()
    sup_cfg = suppression_config or SuppressionConfig()

    # 1. Fetch latest trend
    summary = await fetch_latest_trend(pool, ticker, window)
    if summary is None:
        logger.info("No trend data for %s/%s — skipping recommendation", ticker, window)
        return None

    # 1b. Check for duplicate: evaluate eligibility early for dedup comparison
    preliminary_result = evaluate_eligibility(summary, cfg)
    if await _is_duplicate_recommendation(pool, ticker, summary, preliminary_result):
        return None

    # 2. Fetch latest trend projection (Requirement 12.8, 12.9)
    projection = await fetch_latest_projection(pool, ticker, window)
    # Exclude low-confidence projections from influencing recommendation
    # eligibility (Requirement 12.9). The projection is still passed to
    # build_recommendation for informational display, but marked as
    # low_confidence so it won't affect thesis or time_horizon.
    effective_projection = projection
    if projection is not None and projection.low_confidence:
        effective_projection = projection  # still passed, but build_thesis checks low_confidence

    # 3. Evaluate data quality suppression (Requirement 7.4)
    quality_ctx = await fetch_data_quality_context(pool, ticker, window)
    suppression = evaluate_suppression(
        summary, quality_ctx=quality_ctx, config=sup_cfg, reference_time=reference_time,
    )

    # 4. Evaluate eligibility (use preliminary result, already computed for dedup)
    result = preliminary_result

    # Apply suppression: force mode to informational if suppressed
    if suppression.suppressed:
        result = EligibilityResult(
            eligible=False,
            action=result.action,
            mode=RecommendationMode.INFORMATIONAL,
            position_sizing=result.position_sizing,
            rejection_reasons=result.rejection_reasons,
            time_horizon=result.time_horizon,
            invalidation_conditions=result.invalidation_conditions,
        )

    # 5. Optional LLM thesis rewrite — only for trading-eligible recommendations
    #    to avoid the LLM bottleneck on informational/suppressed recs.
    llm_thesis: str | None = None
    if ollama_config is not None and result.eligible and not suppression.suppressed:
        deterministic_thesis = build_thesis(summary, result, projection=effective_projection)
        llm_thesis = await rewrite_thesis_with_llm(
            deterministic_thesis=deterministic_thesis,
            summary=summary,
            config=ollama_config,
            pool=pool,
        )
        # If the LLM returned the same text as the deterministic thesis,
        # treat it as a no-op (fallback was used).
        if llm_thesis == deterministic_thesis:
            llm_thesis = None

    # 6. Build recommendation
    rec = build_recommendation(
        summary, result, reference_time, llm_thesis=llm_thesis,
        suppression_result=suppression,
        projection=effective_projection,
    )

    # 7. Persist recommendation, evidence citations, and risk evaluation
    rec_id = await persist_recommendation(
        pool,
        rec,
        supporting_ids=list(summary.top_supporting_evidence),
        opposing_ids=list(summary.top_opposing_evidence),
        eligibility_result=result,
    )

    # 7b. Capture prediction snapshot for model validation (Requirements 1.1, 1.6)
    try:
        all_doc_ids = list(summary.top_supporting_evidence) + list(summary.top_opposing_evidence)
        evidence_signals, evidence_docs = await _fetch_evidence_for_snapshot(
            pool, all_doc_ids,
        )
        await create_prediction_snapshot(
            pool, rec, summary, evidence_signals, evidence_docs,
        )
    except Exception:
        logger.warning(
            "Failed to create prediction snapshot for %s/%s — recommendation "
            "persisted but snapshot creation failed",
            ticker, rec_id, exc_info=True,
        )

    # 8. Publish prediction facts to analytical tables (Requirement 9.4)
    if minio_client is not None:
        try:
            lake_refs = publish_recommendation_facts(
                minio_client,
                rec,
                trend_direction=summary.trend_direction.value,
                trend_strength=summary.trend_strength,
            )
            logger.info(
                "Published analytical facts for %s: %s",
                ticker, lake_refs,
            )
        except Exception:
            logger.warning(
                "Failed to publish analytical facts for %s/%s — recommendation "
                "persisted but lake publication failed",
                ticker, rec_id, exc_info=True,
            )

    logger.info(
        "Generated recommendation %s for %s: action=%s mode=%s confidence=%.3f "
        "eligible=%s suppressed=%s quality_score=%.3f llm_thesis=%s projection=%s",
        rec_id, ticker, rec.action.value, rec.mode.value, rec.confidence,
        result.eligible, suppression.suppressed, suppression.data_quality_score,
        llm_thesis is not None,
        projection.projected_direction if projection else "none",
    )

    # Prometheus metrics
    RECOMMENDATION_GENERATED.labels(action=rec.action.value, mode=rec.mode.value).inc()
    RECOMMENDATION_CONFIDENCE.observe(rec.confidence)
    if suppression.suppressed:
        RECOMMENDATION_SUPPRESSED.inc()

    return rec


# ---------------------------------------------------------------------------
# Batch: generate recommendations for multiple tickers
# ---------------------------------------------------------------------------


async def generate_recommendations_batch(
    pool: asyncpg.Pool,
    tickers: list[str],
    window: str = TrendWindow.SEVEN_DAY.value,
    config: EligibilityConfig | None = None,
    ollama_config: OllamaConfig | None = None,
    suppression_config: SuppressionConfig | None = None,
    minio_client: Minio | None = None,
) -> list[Recommendation]:
    """Generate recommendations for a list of tickers.

    Processes each ticker sequentially. Returns only the successfully
    generated recommendations (tickers with no trend data are skipped).

    If ``ollama_config`` is provided, each recommendation's thesis will
    be rewritten using the LLM wording layer.
    """
    results: list[Recommendation] = []
    reference_time = datetime.now(timezone.utc)

    for ticker in tickers:
        rec = await generate_recommendation(
            pool, ticker, window, config, reference_time,
            ollama_config=ollama_config,
            suppression_config=suppression_config,
            minio_client=minio_client,
        )
        if rec is not None:
            results.append(rec)

    logger.info(
        "Batch recommendation: %d/%d tickers produced recommendations",
        len(results), len(tickers),
    )
    return results
