"""Signed-token file delivery and short links for crawler output.

Patterns adapted from mcp_families/mcp_delivery_tokens.py and
mcp_backend_linkmanager.py.
"""

from __future__ import annotations

import base64
import hashlib
import hmac
import json
import mimetypes
import os
import random
import re
import secrets
import string
import threading
import time
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Optional

from starlette.responses import JSONResponse, RedirectResponse, Response

# ---------------------------------------------------------------------------
# Config
# ---------------------------------------------------------------------------
_BASE62 = string.digits + string.ascii_letters


@dataclass(frozen=True)
class FileServerConfig:
    public_url: str
    file_secret: str
    file_ttl_seconds: int
    markdown_route_path: str
    image_route_path: str
    short_link_route_path: str
    sites_root: Path

    @classmethod
    def from_env(cls, sites_root: Path | str | None = None) -> "FileServerConfig":
        public_url = os.getenv("CRAWLER_PUBLIC_BASE_URL", "").rstrip("/")
        file_secret = os.getenv(
            "CRAWLER_FILE_SECRET",
            secrets.token_hex(32),
        )
        file_ttl_seconds = int(os.getenv("CRAWLER_FILE_TTL_SECONDS", "1800"))
        markdown_route_path = os.getenv("CRAWLER_MARKDOWN_ROUTE_PATH", "/crawler/markdown").rstrip("/")
        image_route_path = os.getenv("CRAWLER_IMAGE_ROUTE_PATH", "/crawler/image").rstrip("/")
        short_link_route_path = os.getenv("CRAWLER_SHORT_LINK_PATH", "/l").rstrip("/") or "/l"
        if sites_root is None:
            sites_root = Path(os.getenv("CRAWLER_OUT", "sites_crawled")).resolve()
        else:
            sites_root = Path(sites_root)
        return cls(
            public_url=public_url,
            file_secret=file_secret,
            file_ttl_seconds=max(60, min(file_ttl_seconds, 86400)),
            markdown_route_path=markdown_route_path,
            image_route_path=image_route_path,
            short_link_route_path=short_link_route_path,
            sites_root=sites_root,
        )


_config_lock = threading.Lock()
_config: FileServerConfig | None = None


def _get_config() -> FileServerConfig:
    global _config
    if _config is not None:
        return _config
    with _config_lock:
        if _config is None:
            _config = FileServerConfig.from_env()
    return _config


def init_config(sites_root: Path | str) -> FileServerConfig:
    global _config
    with _config_lock:
        _config = FileServerConfig.from_env(sites_root=sites_root)
    return _config


# ---------------------------------------------------------------------------
# HMAC token helpers
# ---------------------------------------------------------------------------
def _b64url_encode(raw: bytes) -> str:
    return base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=")


def _b64url_decode(value: str) -> bytes:
    pad = "=" * (-len(value) % 4)
    return base64.urlsafe_b64decode(value + pad)


def _sign_payload(payload: dict, secret: str) -> str:
    raw = json.dumps(payload, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
    sig = hmac.new(secret.encode("utf-8"), raw, hashlib.sha256).digest()
    return f"{_b64url_encode(raw)}.{_b64url_encode(sig)}"


def _verify_signed_token(token: str, secret: str) -> dict | None:
    try:
        payload_part, sig_part = token.split(".", 1)
        raw = _b64url_decode(payload_part)
        expected_sig = hmac.new(secret.encode("utf-8"), raw, hashlib.sha256).digest()
        actual_sig = _b64url_decode(sig_part)
        if not hmac.compare_digest(expected_sig, actual_sig):
            return None
        payload = json.loads(raw.decode("utf-8"))
        if int(payload.get("exp", 0)) < int(time.time()):
            return None
        return payload
    except Exception:
        return None


# ---------------------------------------------------------------------------
# Public URL builders
# ---------------------------------------------------------------------------
def build_public_markdown_url(token: str) -> str | None:
    config = _get_config()
    if not config.public_url:
        return None
    return f"{config.public_url}{config.markdown_route_path}/{token}"


def build_public_image_url(token: str) -> str | None:
    config = _get_config()
    if not config.public_url:
        return None
    return f"{config.public_url}{config.image_route_path}/{token}"


# ---------------------------------------------------------------------------
# Token builders
# ---------------------------------------------------------------------------
def build_markdown_token(
    domain: str,
    file_rel: str,
    expires_in_seconds: int | None = None,
) -> tuple[str, int]:
    config = _get_config()
    ttl = int(expires_in_seconds or config.file_ttl_seconds)
    ttl = max(60, min(ttl, 86400))
    exp = int(time.time()) + ttl
    payload = {"domain": domain, "file": file_rel, "exp": exp}
    return _sign_payload(payload, config.file_secret), exp


def build_image_token(
    domain: str,
    image_rel: str,
    expires_in_seconds: int | None = None,
) -> tuple[str, int]:
    config = _get_config()
    ttl = int(expires_in_seconds or config.file_ttl_seconds)
    ttl = max(60, min(ttl, 86400))
    exp = int(time.time()) + ttl
    payload = {"domain": domain, "image": image_rel, "exp": exp}
    return _sign_payload(payload, config.file_secret), exp


def verify_file_token(token: str) -> dict | None:
    return _verify_signed_token(token, _get_config().file_secret)


# ---------------------------------------------------------------------------
# Path resolution
# ---------------------------------------------------------------------------
def _resolve_and_validate_path(domain: str, rel: str) -> Path | None:
    config = _get_config()
    domain_dir = (config.sites_root / domain).resolve()
    if not domain_dir.exists() or not domain_dir.is_dir():
        return None
    resolved = (domain_dir / rel).resolve()
    if not resolved.as_posix().startswith(domain_dir.as_posix() + "/"):
        return None
    if not resolved.exists() or not resolved.is_file():
        return None
    return resolved


# ---------------------------------------------------------------------------
# Image path rewriting in markdown
# ---------------------------------------------------------------------------
_image_md_pattern = re.compile(r'!\[([^\]]*)\]\((images/[^)\s]+)(?:\s+"[^"]*")?\s*\)')
_image_html_src_pattern = re.compile(r'src="(images/[^"]+)"', re.IGNORECASE)

def _rewrite_image_refs(markdown: str, domain: str, ttl: int | None = None) -> str:
    config = _get_config()
    if not config.public_url:
        return markdown

    def _make_url(rel: str) -> str | None:
        token, _ = build_image_token(domain, rel, expires_in_seconds=ttl)
        full_url = build_public_image_url(token)
        if not full_url:
            return None
        short, _, _ = shorten_public_url(full_url)
        return short or full_url

    def _replace_md(m: re.Match) -> str:
        alt = m.group(1)
        rel = m.group(2)
        url = _make_url(rel)
        return f"![{alt}]({url})" if url else m.group(0)

    def _replace_html(m: re.Match) -> str:
        rel = m.group(1)
        url = _make_url(rel)
        return f'src="{url}"' if url else m.group(0)

    markdown = _image_md_pattern.sub(_replace_md, markdown)
    markdown = _image_html_src_pattern.sub(_replace_html, markdown)
    return markdown


# ---------------------------------------------------------------------------
# Short link manager (BASE62, in-memory + optional SQLite)
# ---------------------------------------------------------------------------
_short_lock = threading.Lock()
_code_to_url: dict[str, str] = {}
_url_to_code: dict[str, str] = {}
_short_sqlite_path: str | None = None


def _init_short_store(sqlite_path: str | None = None) -> None:
    global _short_sqlite_path
    _short_sqlite_path = str(sqlite_path).strip() if sqlite_path else None
    if not _short_sqlite_path:
        return
    import sqlite3
    conn = sqlite3.connect(_short_sqlite_path)
    try:
        cur = conn.cursor()
        cur.execute(
            "CREATE TABLE IF NOT EXISTS short_links (code TEXT PRIMARY KEY, url TEXT NOT NULL UNIQUE, created_at INTEGER NOT NULL)"
        )
        cur.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_short_links_url ON short_links(url)")
        conn.commit()
    finally:
        conn.close()


def _generate_code(length: int = 6) -> str:
    return "".join(random.choice(_BASE62) for _ in range(max(4, int(length or 6))))


def shorten_url(url: str, code_length: int = 6) -> str:
    value = str(url or "").strip()
    if not value:
        raise ValueError("url must not be empty")

    with _short_lock:
        existing = _url_to_code.get(value)
        if existing:
            return existing

        if _short_sqlite_path:
            import sqlite3
            conn = sqlite3.connect(_short_sqlite_path)
            try:
                cur = conn.cursor()
                cur.execute("SELECT code FROM short_links WHERE url = ?", (value,))
                row = cur.fetchone()
            finally:
                conn.close()
            if row and row[0]:
                code = str(row[0])
                _url_to_code[value] = code
                _code_to_url[code] = value
                return code

        while True:
            code = _generate_code(code_length)
            if code in _code_to_url:
                continue
            if _short_sqlite_path:
                import sqlite3
                conn = sqlite3.connect(_short_sqlite_path)
                try:
                    cur = conn.cursor()
                    cur.execute("SELECT 1 FROM short_links WHERE code = ?", (code,))
                    if cur.fetchone() is not None:
                        continue
                finally:
                    conn.close()
            _code_to_url[code] = value
            _url_to_code[value] = code
            if _short_sqlite_path:
                import sqlite3
                conn = sqlite3.connect(_short_sqlite_path)
                try:
                    cur = conn.cursor()
                    cur.execute(
                        "INSERT OR IGNORE INTO short_links(code, url, created_at) VALUES (?, ?, ?)",
                        (code, value, int(time.time())),
                    )
                    conn.commit()
                finally:
                    conn.close()
            return code


def resolve_short_code(code: str) -> Optional[str]:
    key = str(code or "").strip()
    if not key:
        return None

    with _short_lock:
        value = _code_to_url.get(key)
        if value:
            return value

    if not _short_sqlite_path:
        return None

    import sqlite3
    conn = sqlite3.connect(_short_sqlite_path)
    try:
        cur = conn.cursor()
        cur.execute("SELECT url FROM short_links WHERE code = ?", (key,))
        row = cur.fetchone()
    finally:
        conn.close()

    if row and row[0]:
        value = str(row[0])
        with _short_lock:
            _code_to_url[key] = value
            _url_to_code[value] = key
        return value

    return None


def shorten_public_url(
    url: str,
    code_length: int | None = None,
) -> tuple[str | None, str | None, str | None]:
    config = _get_config()
    value = str(url or "").strip()
    if not value:
        return None, None, None

    route_base = config.short_link_route_path
    code = shorten_url(value, code_length=code_length or 6)
    route_path = f"{route_base}/{code}"
    short_url = f"{config.public_url}{route_path}" if config.public_url else None
    return short_url, route_path, code


# ---------------------------------------------------------------------------
# Route handlers
# ---------------------------------------------------------------------------
async def _serve_markdown(request) -> Response:
    token = request.path_params.get("token", "")
    payload = verify_file_token(token)
    if not payload:
        return JSONResponse({"error": "invalid_or_expired_token"}, status_code=404)

    domain = str(payload.get("domain") or "").strip()
    file_rel = str(payload.get("file") or "").strip()
    if not domain or not file_rel:
        return JSONResponse({"error": "invalid_token_payload"}, status_code=400)

    path = _resolve_and_validate_path(domain, file_rel)
    if path is None:
        return JSONResponse({"error": "file_not_found"}, status_code=404)

    content = path.read_text(encoding="utf-8")
    content = _rewrite_image_refs(content, domain)

    headers = {
        "Content-Type": "text/markdown; charset=utf-8",
        "Cache-Control": "private, max-age=300",
        "X-Robots-Tag": "noindex, nofollow",
    }
    return Response(content=content, media_type="text/markdown; charset=utf-8", headers=headers)


async def _serve_image(request) -> Response:
    token = request.path_params.get("token", "")
    payload = verify_file_token(token)
    if not payload:
        return JSONResponse({"error": "invalid_or_expired_token"}, status_code=404)

    domain = str(payload.get("domain") or "").strip()
    image_rel = str(payload.get("image") or "").strip()
    if not domain or not image_rel:
        return JSONResponse({"error": "invalid_token_payload"}, status_code=400)

    path = _resolve_and_validate_path(domain, image_rel)
    if path is None:
        return JSONResponse({"error": "file_not_found"}, status_code=404)

    content = path.read_bytes()
    content_type, _ = mimetypes.guess_type(str(path))
    if not content_type or not content_type.startswith("image/"):
        content_type = "application/octet-stream"

    headers = {
        "Content-Disposition": f"inline; filename=\"{path.name}\"",
        "Cache-Control": "private, max-age=300",
        "X-Robots-Tag": "noindex, nofollow",
    }
    return Response(content=content, media_type=content_type, headers=headers)


async def _resolve_short_link(request) -> Response:
    code = request.path_params.get("code", "")
    url = resolve_short_code(code)
    if not url:
        return JSONResponse({"error": "link_not_found"}, status_code=404)
    return RedirectResponse(url=url, status_code=307)


# ---------------------------------------------------------------------------
# Route registration
# ---------------------------------------------------------------------------
def register_routes(app) -> None:
    config = _get_config()

    from starlette.routing import Route

    if hasattr(app, "add_api_route"):
        app.routes.append(Route(f"{config.markdown_route_path}/{{token:path}}", _serve_markdown, methods=["GET"]))
        app.routes.append(Route(f"{config.image_route_path}/{{token:path}}", _serve_image, methods=["GET"]))
        app.routes.append(Route(f"{config.short_link_route_path}/{{code}}", _resolve_short_link, methods=["GET"]))
        return

    routes = getattr(app, "routes", None)
    if isinstance(routes, list):
        routes.append(Route(f"{config.markdown_route_path}/{{token:path}}", _serve_markdown, methods=["GET"]))
        routes.append(Route(f"{config.image_route_path}/{{token:path}}", _serve_image, methods=["GET"]))
        routes.append(Route(f"{config.short_link_route_path}/{{code}}", _resolve_short_link, methods=["GET"]))
        return

    raise TypeError(f"Unsupported app type for file server routes: {type(app)!r}")
