# SPDX-License-Identifier: AGPL-3.0-only
# Copyright (c) 2026 sol pbc

import json
import logging
import os
import re
import socket
from datetime import datetime, timezone
from pathlib import Path

from flask import Blueprint, Response, current_app, jsonify, request

from solstone.convey import backlog_copy, state
from solstone.convey.backlog_view import stuck_rows, verdict
from solstone.convey.reasons import (
    FILE_NOT_FOUND,
    FILE_READ_FAILED,
    INVALID_DAY,
    INVALID_PATH,
    INVALID_REQUEST_VALUE,
    MISSING_REQUIRED_FIELD,
    OBSERVER_RESTART_FAILED,
    REPROCESS_ALREADY_COMPLETE,
    REPROCESS_PAST_ONLY,
    REPROCESS_UNREACHABLE,
)
from solstone.convey.utils import error_response
from solstone.think.brain_health import (
    HEADLINES,
    build_brain_snapshot,
    request_brain_refresh,
)
from solstone.think.callosum import callosum_send
from solstone.think.reprocess import (
    FLAVOR_FROM_SCRATCH,
    FLAVOR_PROCESS_NOW,
    ReprocessCode,
    reprocess_day,
)
from solstone.think.streams import stream_name
from solstone.think.talent_runs import AgentFailureScan, read_unresolved_agent_failures

logger = logging.getLogger(__name__)

health_bp = Blueprint(
    "app:health",
    __name__,
    url_prefix="/app/health",
    static_folder="static",
    static_url_path="/static",
)


# Supervisor currently registers one observer-facing processing service: "sense".
# Observer rows are per registration key, but reconnect restarts this shared worker.
# Keep this endpoint whitelist local until supervisor exposes a public service list.
OBSERVER_RESTART_SERVICES = {"sense"}


def _load_backlog() -> dict | None:
    stats_path = os.path.join(state.journal_root, "stats.json")
    if not os.path.isfile(stats_path):
        return None
    try:
        with open(stats_path, "r", encoding="utf-8") as f:
            data = json.load(f)
    except (OSError, ValueError):
        logger.exception("Failed to read backlog from stats.json")
        return None
    backlog = data.get("backlog")
    return backlog if isinstance(backlog, dict) else None


def _safe_brain_snapshot() -> dict:
    try:
        return build_brain_snapshot(datetime.now(timezone.utc), surface="health")
    except Exception:
        logger.exception("Failed to build health brain snapshot")
        return {
            "state": "unknown",
            "headline": HEADLINES["unknown"],
            "reason_code": "brain_record_unavailable",
            "reason_text": "brain record unavailable",
            "failing_component": None,
            "action": {"label": "check again", "refresh": True},
            "identity": {"lane": None, "provider": None, "model": None},
            "evidence": {
                "observed_at": None,
                "age_seconds": None,
                "age_text": None,
            },
            "components": {
                "generate": {
                    "status": None,
                    "reason_code": None,
                    "reason_text": "unknown",
                    "observed_at": None,
                },
                "cogitate": {
                    "status": None,
                    "reason_code": None,
                    "reason_text": "unknown",
                    "observed_at": None,
                },
            },
            "progressing": False,
        }


def _build_agent_error_seed(scan: AgentFailureScan) -> list[dict]:
    return [
        {
            "type": "agent",
            "id": failure.use_id,
            "name": failure.name,
            "ts": failure.ts,
            "service": "cortex",
            "error": "talent error",
            "reason_code": failure.reason_code,
            "provider": failure.provider,
            "model": failure.model,
        }
        for failure in scan.failures
    ]


def _errors_today_label(count: int | None) -> str:
    return "error today" if count == 1 else "errors today"


@health_bp.route("/")
def index() -> Response:
    return current_app.send_static_file("shell.html")


@health_bp.get("/api/state")
def api_state() -> Response:
    backlog = _load_backlog()
    agent_failure_scan = read_unresolved_agent_failures()
    agent_error_seed = _build_agent_error_seed(agent_failure_scan)
    agent_error_count = len(agent_error_seed)
    return jsonify(
        {
            "backlog": {
                "verdict": verdict(backlog),
                "stuck_rows": stuck_rows(backlog),
                "copy": {
                    "bucket_heading": backlog_copy.BACKLOG_BUCKET_HEADING,
                    "bucket_description": backlog_copy.BACKLOG_BUCKET_DESCRIPTION,
                    "day_badge": backlog_copy.BACKLOG_DAY_BADGE,
                    "action_process_now": backlog_copy.BACKLOG_ACTION_PROCESS_NOW,
                    "action_redo_scratch": backlog_copy.BACKLOG_ACTION_REDO_SCRATCH,
                    "confirm_redo_scratch": backlog_copy.BACKLOG_CONFIRM_REDO_SCRATCH,
                    "queued_feedback": backlog_copy.BACKLOG_QUEUED_FEEDBACK,
                },
            },
            "agent_errors": {
                "items": agent_error_seed,
                "ok": agent_failure_scan.ok,
                "count": agent_error_count,
                "label": _errors_today_label(
                    agent_error_count if agent_failure_scan.ok else None
                ),
            },
        }
    )


@health_bp.get("/api/log")
def get_log():
    path = request.args.get("path")
    if not path:
        return error_response(MISSING_REQUIRED_FIELD, detail="Missing path parameter")

    if not re.fullmatch(r"\d{8}/health/[^/]+\.log", path):
        return error_response(INVALID_PATH, detail="Invalid path")

    journal_root = Path(state.journal_root).resolve()
    try:
        file_path = (Path(state.journal_root) / path).resolve()
    except ValueError:
        return error_response(INVALID_PATH, detail="Invalid path")
    try:
        file_path.relative_to(journal_root)
    except ValueError:
        return error_response(INVALID_PATH, detail="Invalid path")

    if not file_path.exists():
        return error_response(FILE_NOT_FOUND, detail="Log file not found")

    try:
        content = file_path.read_text(encoding="utf-8")
    except IOError:
        return error_response(FILE_READ_FAILED, detail="Failed to read log file")

    return jsonify(content=content, path=path)


@health_bp.route("/api/info")
def api_info():
    return jsonify(
        {
            "hostname": stream_name(host=socket.gethostname()),
            "brain": _safe_brain_snapshot(),
        }
    )


@health_bp.post("/api/brain/check")
def check_brain():
    ok = request_brain_refresh(surface="health")
    response = {"ok": ok, "brain": _safe_brain_snapshot()}
    if not ok:
        response["error"] = "check_not_started"
    return jsonify(response)


@health_bp.post("/api/retry-import")
def retry_import():
    data = request.get_json(silent=True) or {}
    if not data.get("import_id"):
        return error_response(MISSING_REQUIRED_FIELD, detail="Missing import_id")
    stage = data.get("stage")
    message = "Import retry will be available in a future update"
    if stage:
        message = (
            f"Import retry from stage {stage} will be available in a future update"
        )
    return jsonify(
        status="not_implemented",
        message=message,
    ), 501


@health_bp.post("/api/restart-observer")
def restart_observer():
    data = request.get_json(silent=True) or {}
    service = data.get("service")
    if not service:
        return error_response(MISSING_REQUIRED_FIELD, detail="Missing service")
    if service not in OBSERVER_RESTART_SERVICES:
        return error_response(INVALID_REQUEST_VALUE, detail="Unknown observer service")

    ok = callosum_send("supervisor", "restart", service=service)
    if not ok:
        return error_response(
            OBSERVER_RESTART_FAILED,
            detail="Could not reach the supervisor",
        )

    return jsonify(status="restart_requested", service=service)


@health_bp.post("/api/reprocess")
def reprocess():
    data = request.get_json(silent=True) or {}
    day = data.get("day")
    flavor = data.get("flavor")
    if not day:
        return error_response(MISSING_REQUIRED_FIELD, detail="Missing day")
    if flavor not in (FLAVOR_PROCESS_NOW, FLAVOR_FROM_SCRATCH):
        return error_response(INVALID_REQUEST_VALUE, detail="Unknown reprocess flavor")

    outcome = reprocess_day(day, flavor)
    code = outcome.code
    if code in (
        ReprocessCode.PROCESS_NOW_SUBMITTED,
        ReprocessCode.FROM_SCRATCH_SUBMITTED,
    ):
        return jsonify(status="queued", day=day)
    if code is ReprocessCode.ALREADY_COMPLETE:
        return jsonify(
            status="already_complete",
            day=day,
            message=REPROCESS_ALREADY_COMPLETE.message,
            reason_code=REPROCESS_ALREADY_COMPLETE.code,
        )
    if code is ReprocessCode.PAST_ONLY:
        return error_response(REPROCESS_PAST_ONLY)
    if code is ReprocessCode.UNREACHABLE:
        return error_response(REPROCESS_UNREACHABLE)
    return error_response(INVALID_DAY)
