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

import json
import logging
import math
import re
import secrets
import time
from datetime import datetime
from pathlib import Path
from typing import Any, Optional

from flask import Response, jsonify

from solstone.convey.reasons import INVALID_PATH, Reason
from solstone.think.journal_io import contained_path, day_path, get_journal

DATE_RE = re.compile(r"\d{8}")
_REQUEST_ID_ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"
_LOGGER = logging.getLogger("solstone.convey.utils")


def generate_request_id() -> str:
    """Return 12-char Crockford base32 ULID-style request ID."""
    timestamp_ms = (time.time_ns() // 1_000_000) & ((1 << 48) - 1)
    random_bits = int.from_bytes(secrets.token_bytes(2), "big") & 0x0FFF
    value = (timestamp_ms << 12) | random_bits

    chars = []
    for shift in range(55, -1, -5):
        chars.append(_REQUEST_ID_ALPHABET[(value >> shift) & 0x1F])
    return "".join(chars)


def format_date(date_str: str) -> str:
    """Convert YYYYMMDD to 'Wednesday April 2nd' format."""
    try:
        date_obj = datetime.strptime(date_str, "%Y%m%d")
        day = date_obj.day
        if 10 <= day % 100 <= 20:
            suffix = "th"
        else:
            suffix = {1: "st", 2: "nd", 3: "rd"}.get(day % 10, "th")
        return date_obj.strftime(f"%A %B {day}{suffix}")
    except ValueError:
        return date_str


def format_month_day(ts_ms: int | float) -> str:
    """Lowercase 'mon d' from the local date of an epoch-ms timestamp."""
    return datetime.fromtimestamp(ts_ms / 1000).strftime("%b %-d").lower()


def format_date_short(date_str: str) -> str:
    """Convert YYYYMMDD to smart relative/short format.

    Returns:
        - "Today", "Yesterday", "Tomorrow" for those days
        - Day name (e.g., "Wednesday") for dates within the past 6 days
        - "Sat Nov 29" for other dates in current/recent year
        - "Sat Nov 29 '24" for dates >6 months ago in a different year
    """
    try:
        date_obj = datetime.strptime(date_str, "%Y%m%d")
        today = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
        date_normalized = date_obj.replace(hour=0, minute=0, second=0, microsecond=0)
        delta_days = (date_normalized - today).days

        # Today, Yesterday, Tomorrow
        if delta_days == 0:
            return "Today"
        elif delta_days == -1:
            return "Yesterday"
        elif delta_days == 1:
            return "Tomorrow"
        # Within past 6 days - use day name
        elif -6 <= delta_days < 0:
            return date_obj.strftime("%A")
        # Default short format
        else:
            short = date_obj.strftime("%a %b %-d")
            # Add year suffix if >6 months ago AND different year
            months_ago = (today.year - date_obj.year) * 12 + (
                today.month - date_obj.month
            )
            if months_ago > 6 and date_obj.year != today.year:
                short += date_obj.strftime(" '%y")
            return short
    except ValueError:
        return date_str


def _plural(value: int, unit: str) -> str:
    return f"{value} {unit}{'s' if value != 1 else ''}"


def relative_time(seconds: int | float) -> str:
    """Return canonical human readable duration for ``seconds``."""
    if not math.isfinite(seconds) or seconds < 0:
        seconds = 0
    seconds = int(seconds)
    if seconds < 60:
        return _plural(seconds, "second")
    minutes = seconds // 60
    if minutes < 60:
        return _plural(minutes, "minute")
    hours = minutes // 60
    if hours < 24:
        return _plural(hours, "hour")
    days = hours // 24
    if days < 7:
        return _plural(days, "day")
    if days < 28:
        return _plural(days // 7, "week")
    if days < 60:
        return "1 month"
    return _plural(days // 30, "month")


def time_since(epoch: int) -> str:
    """Return short human readable age for ``epoch`` seconds."""
    delta_seconds = max(0, time.time() - epoch)
    return f"{relative_time(delta_seconds)} ago"


def spawn_agent(
    prompt: str,
    name: str,
    config: Optional[dict[str, Any]] = None,
    use_id: Optional[str] = None,
) -> str:
    """Spawn a Cortex agent and return the use_id.

    Thin wrapper around cortex_request that ensures imports are handled
    and returns the use_id directly.

    Args:
        prompt: The task or question for the agent
        name: Agent name - system (e.g., "default") or app-qualified (e.g., "entities:entity_assist")
        config: Additional launch configuration (facet, session_id, etc.)
        use_id: Optional pre-reserved Cortex use_id to reuse for the request

    Returns:
        use_id string (timestamp-based).

    Raises:
        ValueError: If config is invalid
        CortexSpawnUnavailable: If the request cannot reach Callosum
        CortexNotClaimed: If Cortex does not durably claim the request
    """
    from solstone.think.cortex_client import cortex_request

    return cortex_request(
        prompt=prompt,
        name=name,
        config=config,
        use_id=use_id,
    )


def parse_pagination_params(
    default_limit: int = 20,
    max_limit: int = 100,
    min_limit: int = 1,
) -> tuple[int, int]:
    """Parse and validate pagination parameters from request.args.

    Extracts limit and offset from Flask request.args, validates them,
    and enforces bounds to prevent API abuse.

    Args:
        default_limit: Default value for limit if not provided or invalid
        max_limit: Maximum allowed value for limit
        min_limit: Minimum allowed value for limit

    Returns:
        (limit, offset) tuple with validated integers

    Example:
        limit, offset = parse_pagination_params(default_limit=20, max_limit=100)
    """
    from flask import request

    # Parse limit with error handling
    try:
        limit = int(request.args.get("limit", default_limit))
    except (ValueError, TypeError):
        limit = default_limit

    # Parse offset with error handling
    try:
        offset = int(request.args.get("offset", 0))
    except (ValueError, TypeError):
        offset = 0

    # Enforce bounds
    limit = max(min_limit, min(limit, max_limit))
    offset = max(0, offset)

    return limit, offset


def load_json(path: str | Path) -> dict | list | None:
    """Load JSON file with consistent error handling.

    Args:
        path: Path to JSON file (string or Path object)

    Returns:
        Parsed JSON data (dict or list), or None if file doesn't exist or can't be parsed

    Example:
        data = load_json("config.json")
        if data:
            print(data.get("key"))
    """
    try:
        with open(path, "r", encoding="utf-8") as f:
            return json.load(f)
    except (FileNotFoundError, json.JSONDecodeError, OSError):
        return None


def save_json(
    path: str | Path,
    data: dict | list,
    indent: int = 2,
    add_newline: bool = True,
) -> bool:
    """Save JSON file with consistent formatting.

    Args:
        path: Path to JSON file (string or Path object)
        data: Data to serialize (dict or list)
        indent: Indentation level (default: 2)
        add_newline: Whether to add trailing newline for readability (default: True)

    Returns:
        True if successful, False otherwise

    Example:
        success = save_json("config.json", {"key": "value"})
    """
    try:
        with open(path, "w", encoding="utf-8") as f:
            json.dump(data, f, indent=indent, ensure_ascii=False)
            if add_newline:
                f.write("\n")
        return True
    except (OSError, TypeError):
        return False


def safe_journal_path(relpath: str) -> tuple[Path | None, tuple[Response, int] | None]:
    """Contain a request-supplied journal-relative path at the HTTP boundary.

    Thin wrapper over contained_path: validates and contains, never writes.
    Both rejection classes — the lexical ValueError from resolve_journal_path
    (empty/absolute/backslash/dot-dot) and PathEscapeError from an in-journal
    symlink that escapes the real root (a ValueError subclass) — converge on the
    standard INVALID_PATH owner-voice envelope.

    Returns (contained_absolute_path, None) on success, or
    (None, error_response(INVALID_PATH)) on any rejection.
    """
    try:
        return contained_path(get_journal(), relpath), None
    except ValueError:
        return None, error_response(INVALID_PATH)


def safe_day_path(
    day: str, rel_path: str
) -> tuple[Path | None, tuple[Response, int] | None]:
    """Contain a request-supplied path at the chronicle-day HTTP boundary.

    Thin wrapper over contained_path: validates and contains under the chronicle
    day directory, never writes. Rejections converge on the standard
    INVALID_PATH envelope at 403.
    """
    try:
        base = day_path(day, create=False)
        return contained_path(base, rel_path), None
    except ValueError:
        return None, error_response(INVALID_PATH, status=403)


def error_response(
    reason: Reason,
    status: int | None = None,
    *,
    detail: str | None = None,
    extra: dict[str, Any] | None = None,
) -> tuple[Response, int]:
    """Create a standard JSON error response.

    Provides consistent error response format across all API endpoints.

    Args:
        reason: Reason constant
        status: Optional HTTP status override
        detail: Optional implementation-specific context
        extra: Optional additional JSON fields

    Returns:
        Tuple of (jsonify response, status_code) ready for Flask return
    """
    response_status = status if status is not None else reason.status
    return (
        jsonify(
            {
                **(extra or {}),
                "error": reason.message,
                "reason_code": reason.code,
                "detail": detail or "",
            }
        ),
        response_status,
    )


def error_response_with_reason(
    reason: Reason,
    *,
    detail: str | None = None,
    status: int | None = None,
) -> tuple[Response, int]:
    """Pairing/root variant: emits legacy reason key plus standard fields."""
    response_status = status if status is not None else reason.status
    return (
        jsonify(
            {
                "error": reason.message,
                "reason": reason.code,
                "reason_code": reason.code,
                "detail": detail or "",
            }
        ),
        response_status,
    )


def success_response(
    data: dict[str, Any] | None = None, code: int = 200
) -> tuple[Any, int]:
    """Create a standard JSON success response.

    Provides consistent success response format across all API endpoints.

    Args:
        data: Optional dict of additional data to include in response
        code: HTTP status code (default: 200 OK)

    Returns:
        Tuple of (jsonify response, status_code) ready for Flask return

    Example:
        return success_response()  # Returns {"success": True}
        return success_response({"use_id": "123"})  # Returns {"success": True, "use_id": "123"}
    """
    response_data = {"success": True}
    if data:
        response_data.update(data)
    return jsonify(response_data), code


def respond_collection(
    items: list[Any],
    *,
    total: int | None = None,
    cursor: str | None = None,
) -> tuple[Response, int]:
    """Create a standard JSON collection response.

    The canonical way to return a list resource. Shapes the body as
    ``{"items": [...], "total": <int>}`` so routes never return a bare
    top-level JSON array. ``total`` defaults to ``len(items)`` when not
    given; ``next_cursor`` is added only when ``cursor`` is provided.

    Args:
        items: The collection items to return.
        total: Total count of matching items; defaults to ``len(items)``.
        cursor: Opaque pagination cursor for the next page, if any.

    Returns:
        Tuple of (jsonify response, 200) ready for Flask return.
    """
    body: dict[str, Any] = {
        "items": items,
        "total": total if total is not None else len(items),
    }
    if cursor is not None:
        body["next_cursor"] = cursor
    return jsonify(body), 200


def created(
    resource: dict[str, Any],
    *,
    location: str | None = None,
) -> tuple[Response, int]:
    """Create a standard 201 response for a newly-created resource.

    The canonical way to return a freshly-created resource with its
    server-assigned id. Optionally sets a ``Location`` response header.

    Args:
        resource: The created resource (including its server-assigned id).
        location: Optional URL of the created resource for the
            ``Location`` header.

    Returns:
        Tuple of (jsonify response, 201) ready for Flask return.
    """
    response = jsonify(resource)
    if location is not None:
        response.headers["Location"] = location
    return response, 201
