import hashlib
import hmac
import json
from contextlib import asynccontextmanager
from datetime import datetime, timezone
from pathlib import Path
from zoneinfo import ZoneInfo
from xml.etree import ElementTree as ET

from fastapi import Depends, FastAPI, File, Header, HTTPException, Query, Request, UploadFile
from fastapi.responses import FileResponse, HTMLResponse, PlainTextResponse, Response
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates

from .config import settings
from .db import connect, disconnect, get_pool
from .schemas import ArticleUpsert
from .text import excerpt, paragraphs, slugify


ROOT = Path(__file__).resolve().parents[2]
STATIC_DIR = ROOT / "static"
TEMPLATE_DIR = ROOT / "templates"
ALLOWED_UPLOADS = {
    "image/jpeg": ".jpg",
    "image/png": ".png",
    "image/webp": ".webp",
    "image/gif": ".gif",
    "video/mp4": ".mp4",
    "video/webm": ".webm",
}


@asynccontextmanager
async def lifespan(_: FastAPI):
    settings.upload_dir.mkdir(parents=True, exist_ok=True)
    await connect()
    yield
    await disconnect()


app = FastAPI(title=settings.site_name, docs_url=None, redoc_url=None, lifespan=lifespan)
app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
app.mount("/media", StaticFiles(directory=settings.upload_dir, check_dir=False), name="media")
templates = Jinja2Templates(directory=TEMPLATE_DIR)
templates.env.filters["paragraphs"] = paragraphs
templates.env.filters["excerpt"] = excerpt
LOCAL_TZ = ZoneInfo("Asia/Yekaterinburg")
templates.env.filters["ekb_date"] = lambda value: value.astimezone(LOCAL_TZ).strftime("%d.%m.%Y") if value else ""
templates.env.filters["ekb_time"] = lambda value: value.astimezone(LOCAL_TZ).strftime("%H:%M") if value else ""


def site_url(path: str = "") -> str:
    return f"{settings.base_url}{path if path.startswith('/') else '/' + path if path else ''}"


def article_path(article: dict) -> str:
    return f"/news/{article['slug']}"


templates.env.globals.update(
    site_name=settings.site_name,
    site_tagline=settings.site_tagline,
    site_url=site_url,
    article_path=article_path,
)


@app.middleware("http")
async def security_headers(request: Request, call_next):
    response = await call_next(request)
    response.headers.setdefault("X-Content-Type-Options", "nosniff")
    response.headers.setdefault("X-Frame-Options", "DENY")
    response.headers.setdefault("Referrer-Policy", "strict-origin-when-cross-origin")
    response.headers.setdefault("Permissions-Policy", "camera=(), microphone=(), geolocation=()")
    return response


def base_context(request: Request, **extra):
    return {
        "request": request,
        "current_path": request.url.path,
        "canonical_url": site_url(request.url.path),
        "meta_title": f"{settings.site_tagline} — {settings.site_name}",
        "meta_description": "Новости производителей тактического снаряжения, одежды, брони и аксессуаров.",
        "noindex": not settings.site_indexing_enabled or bool(request.query_params.get("q")),
        **extra,
    }


ARTICLE_COLUMNS = """
    a.*,
    COALESCE(
      jsonb_agg(
        jsonb_build_object(
          'type', m.media_type,
          'url', m.url,
          'alt', m.alt_text,
          'width', m.width,
          'height', m.height
        ) ORDER BY m.sort_order, m.id
      ) FILTER (WHERE m.id IS NOT NULL),
      '[]'::jsonb
    ) AS media
"""


def normalize_article(row) -> dict:
    article = dict(row)
    media = article.get("media") or []
    if isinstance(media, str):
        try:
            media = json.loads(media)
        except json.JSONDecodeError:
            media = []
    article["media"] = media if isinstance(media, list) else []
    return article


async def fetch_feed(*, page: int, q: str = "", category: str = "", producer: str = "", page_size: int | None = None):
    pool = await get_pool()
    where = ["a.status='published'", "a.published_at <= NOW()"]
    args: list[object] = []
    if q:
        args.append(f"%{q}%")
        where.append(f"(a.title ILIKE ${len(args)} OR a.lead ILIKE ${len(args)} OR a.body ILIKE ${len(args)} OR a.producer_name ILIKE ${len(args)})")
    if category:
        args.append(category)
        where.append(f"a.category_tag=${len(args)}")
    if producer:
        args.append(producer)
        where.append(f"a.producer_tag=${len(args)}")
    where_sql = " AND ".join(where)
    total = int(await pool.fetchval(f"SELECT COUNT(*) FROM articles a WHERE {where_sql}", *args) or 0)
    limit = max(1, min(50, page_size or settings.page_size))
    offset = (page - 1) * limit
    rows = await pool.fetch(
        f"""
        SELECT {ARTICLE_COLUMNS}
        FROM articles a
        LEFT JOIN article_media m ON m.article_id=a.id
        WHERE {where_sql}
        GROUP BY a.id
        ORDER BY a.published_at DESC, a.id DESC
        LIMIT ${len(args) + 1} OFFSET ${len(args) + 2}
        """,
        *args,
        limit,
        offset,
    )
    return [normalize_article(row) for row in rows], total, limit


@app.get("/", response_class=HTMLResponse)
async def feed(request: Request, page: int = Query(1, ge=1), q: str = Query("", max_length=100)):
    articles, total, limit = await fetch_feed(page=page, q=q.strip())
    pages = max(1, (total + limit - 1) // limit)
    canonical = site_url("/") if page == 1 else site_url(f"/?page={page}")
    return templates.TemplateResponse(
        "feed.html",
        base_context(
            request,
            articles=articles,
            page=page,
            pages=pages,
            total=total,
            q=q.strip(),
            canonical_url=canonical,
        ),
    )


@app.get("/category/{tag}", response_class=HTMLResponse)
async def category_feed(request: Request, tag: str, page: int = Query(1, ge=1)):
    pool = await get_pool()
    category_name = await pool.fetchval("SELECT name FROM categories WHERE tag=$1 AND is_active=TRUE", tag)
    if not category_name:
        raise HTTPException(status_code=404)
    articles, total, limit = await fetch_feed(page=page, category=tag)
    name = str(category_name)
    pages = max(1, (total + limit - 1) // limit)
    return templates.TemplateResponse(
        "taxonomy.html",
        base_context(
            request,
            articles=articles,
            page=page,
            pages=pages,
            heading=name,
            taxonomy_label="Категория",
            canonical_url=site_url(f"/category/{tag}" + (f"?page={page}" if page > 1 else "")),
            meta_title=f"{name}: новости — {settings.site_name}",
            meta_description=f"Последние новости в категории «{name}»: производители, новинки и обзоры снаряжения.",
            noindex=total == 0,
        ),
    )


@app.get("/brand/{tag}", response_class=HTMLResponse)
async def producer_feed(request: Request, tag: str, page: int = Query(1, ge=1)):
    articles, total, limit = await fetch_feed(page=page, producer=tag)
    if not articles and page == 1:
        raise HTTPException(status_code=404)
    name = articles[0]["producer_name"] if articles else tag
    pages = max(1, (total + limit - 1) // limit)
    return templates.TemplateResponse(
        "taxonomy.html",
        base_context(
            request,
            articles=articles,
            page=page,
            pages=pages,
            heading=name,
            taxonomy_label="Производитель",
            canonical_url=site_url(f"/brand/{tag}" + (f"?page={page}" if page > 1 else "")),
            meta_title=f"{name}: новости и новинки — {settings.site_name}",
            meta_description=f"Новости и новинки производителя {name}: снаряжение, одежда и экипировка.",
        ),
    )


@app.get("/news/{slug}", response_class=HTMLResponse)
async def article(request: Request, slug: str):
    pool = await get_pool()
    row = await pool.fetchrow(
        f"""
        SELECT {ARTICLE_COLUMNS}
        FROM articles a
        LEFT JOIN article_media m ON m.article_id=a.id
        WHERE a.slug=$1 AND a.status='published' AND a.published_at <= NOW()
        GROUP BY a.id
        """,
        slug,
    )
    if not row:
        raise HTTPException(status_code=404)
    item = normalize_article(row)
    related = await pool.fetch(
        f"""
        SELECT {ARTICLE_COLUMNS}
        FROM articles a
        LEFT JOIN article_media m ON m.article_id=a.id
        WHERE a.status='published' AND a.id<>$1
          AND (a.category_tag=$2 OR a.producer_tag=$3)
        GROUP BY a.id
        ORDER BY (a.producer_tag=$3) DESC, a.published_at DESC
        LIMIT 3
        """,
        item["id"],
        item["category_tag"],
        item["producer_tag"],
    )
    canonical = site_url(article_path(item))
    image = item["media"][0]["url"] if item["media"] else site_url("/static/favi.png")
    if image.startswith("/"):
        image = site_url(image)
    json_ld = {
        "@context": "https://schema.org",
        "@type": "NewsArticle",
        "headline": item["title"],
        "description": item["seo_description"] or item["lead"] or excerpt(item["body"]),
        "datePublished": item["published_at"].isoformat(),
        "dateModified": item["updated_at"].isoformat(),
        "mainEntityOfPage": canonical,
        "image": [image],
        "author": {"@type": "Organization", "name": settings.site_name},
        "publisher": {
            "@type": "Organization",
            "name": settings.site_name,
            "logo": {"@type": "ImageObject", "url": site_url("/static/favi.png")},
        },
    }
    return templates.TemplateResponse(
        "article.html",
        base_context(
            request,
            article=item,
            related=[normalize_article(x) for x in related],
            canonical_url=canonical,
            meta_title=item["seo_title"] or f"{item['title']} — {settings.site_name}",
            meta_description=item["seo_description"] or item["lead"] or excerpt(item["body"]),
            og_image=image,
            json_ld=json.dumps(json_ld, ensure_ascii=False),
        ),
    )


def require_api_token(x_forma_token: str = Header(default="")) -> None:
    if not settings.publish_api_token or not hmac.compare_digest(x_forma_token, settings.publish_api_token):
        raise HTTPException(status_code=401, detail="invalid token")


@app.post("/api/v1/media", dependencies=[Depends(require_api_token)])
async def upload_media(file: UploadFile = File(...)):
    content_type = (file.content_type or "").split(";", 1)[0].lower()
    if content_type not in ALLOWED_UPLOADS:
        raise HTTPException(status_code=415, detail="unsupported media type")
    data = await file.read(50 * 1024 * 1024 + 1)
    if len(data) > 50 * 1024 * 1024:
        raise HTTPException(status_code=413, detail="file too large")
    digest = hashlib.sha256(data).hexdigest()
    suffix = ALLOWED_UPLOADS[content_type]
    target = settings.upload_dir / digest[:2] / f"{digest}{suffix}"
    target.parent.mkdir(parents=True, exist_ok=True)
    if not target.exists():
        target.write_bytes(data)
    return {"url": f"/media/{digest[:2]}/{target.name}", "sha256": digest, "content_type": content_type}


@app.post("/api/v1/articles", dependencies=[Depends(require_api_token)])
async def upsert_article(payload: ArticleUpsert):
    pool = await get_pool()
    existing = await pool.fetchrow("SELECT id, slug, published_at FROM articles WHERE external_id=$1", payload.external_id)
    requested_slug = slugify(payload.slug or payload.title)
    stable_slug = existing["slug"] if existing else f"{requested_slug}-{slugify(payload.external_id)[-24:]}"
    published_at = payload.published_at or datetime.now(timezone.utc)
    category_tag = slugify(payload.category_tag)
    category_name = await pool.fetchval("SELECT name FROM categories WHERE tag=$1 AND is_active=TRUE", category_tag)
    category_name = str(category_name or payload.category_name).strip()
    async with pool.acquire() as conn:
        async with conn.transaction():
            row = await conn.fetchrow(
                """
                INSERT INTO articles(
                  external_id, slug, title, lead, body, category_name, category_tag,
                  producer_name, producer_tag, source_url, seo_title, seo_description,
                  status, published_at
                )
                VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14)
                ON CONFLICT(external_id) DO UPDATE SET
                  title=EXCLUDED.title,
                  lead=EXCLUDED.lead,
                  body=EXCLUDED.body,
                  category_name=EXCLUDED.category_name,
                  category_tag=EXCLUDED.category_tag,
                  producer_name=EXCLUDED.producer_name,
                  producer_tag=EXCLUDED.producer_tag,
                  source_url=EXCLUDED.source_url,
                  seo_title=EXCLUDED.seo_title,
                  seo_description=EXCLUDED.seo_description,
                  status=EXCLUDED.status,
                  published_at=COALESCE(articles.published_at, EXCLUDED.published_at),
                  updated_at=NOW()
                RETURNING id, slug, status, published_at, updated_at
                """,
                payload.external_id,
                stable_slug,
                payload.title.strip(),
                payload.lead.strip(),
                payload.body.strip(),
                category_name,
                category_tag,
                payload.producer_name.strip(),
                slugify(payload.producer_tag),
                str(payload.source_url) if payload.source_url else None,
                (payload.seo_title or payload.title).strip(),
                (payload.seo_description or payload.lead or excerpt(payload.body)).strip(),
                payload.status,
                published_at,
            )
            await conn.execute("DELETE FROM article_media WHERE article_id=$1", row["id"])
            for index, media in enumerate(payload.media):
                await conn.execute(
                    """
                    INSERT INTO article_media(article_id, media_type, url, alt_text, width, height, sort_order)
                    VALUES($1,$2,$3,$4,$5,$6,$7)
                    """,
                    row["id"], media.type, media.url, media.alt or payload.title,
                    media.width, media.height, media.sort_order or index,
                )
    return {
        "id": row["id"],
        "external_id": payload.external_id,
        "status": row["status"],
        "url": site_url(f"/news/{row['slug']}") if row["status"] == "published" else None,
        "published_at": row["published_at"],
        "updated_at": row["updated_at"],
    }


@app.get("/rss.xml")
async def rss():
    articles, _, _ = await fetch_feed(page=1, page_size=50)
    root = ET.Element("rss", {"version": "2.0", "xmlns:media": "http://search.yahoo.com/mrss/"})
    channel = ET.SubElement(root, "channel")
    ET.SubElement(channel, "title").text = f"{settings.site_name} — {settings.site_tagline}"
    ET.SubElement(channel, "link").text = settings.base_url
    ET.SubElement(channel, "description").text = "Новости производителей тактического снаряжения и экипировки."
    ET.SubElement(channel, "language").text = "ru"
    for item in articles[:50]:
        node = ET.SubElement(channel, "item")
        url = site_url(article_path(item))
        ET.SubElement(node, "title").text = item["title"]
        ET.SubElement(node, "link").text = url
        ET.SubElement(node, "guid", {"isPermaLink": "true"}).text = url
        ET.SubElement(node, "description").text = item["lead"] or excerpt(item["body"])
        ET.SubElement(node, "pubDate").text = item["published_at"].strftime("%a, %d %b %Y %H:%M:%S +0000")
        ET.SubElement(node, "category").text = item["category_name"]
        if item["media"]:
            ET.SubElement(node, "media:content", {"url": site_url(item["media"][0]["url"]) if item["media"][0]["url"].startswith("/") else item["media"][0]["url"], "medium": "image"})
    return Response(ET.tostring(root, encoding="utf-8", xml_declaration=True), media_type="application/rss+xml")


@app.get("/sitemap.xml")
async def sitemap():
    pool = await get_pool()
    rows = await pool.fetch("SELECT slug, updated_at FROM articles WHERE status='published' ORDER BY id")
    categories = await pool.fetch("SELECT category_tag AS tag, MAX(updated_at) AS updated_at FROM articles WHERE status='published' GROUP BY category_tag")
    producers = await pool.fetch("SELECT producer_tag AS tag, MAX(updated_at) AS updated_at FROM articles WHERE status='published' GROUP BY producer_tag")
    root = ET.Element("urlset", {"xmlns": "http://www.sitemaps.org/schemas/sitemap/0.9"})
    entries = [(site_url("/"), None)]
    entries += [(site_url(f"/category/{row['tag']}"), row["updated_at"]) for row in categories]
    entries += [(site_url(f"/brand/{row['tag']}"), row["updated_at"]) for row in producers]
    entries += [(site_url(f"/news/{row['slug']}"), row["updated_at"]) for row in rows]
    for loc, updated in entries:
        node = ET.SubElement(root, "url")
        ET.SubElement(node, "loc").text = loc
        if updated:
            ET.SubElement(node, "lastmod").text = updated.date().isoformat()
    return Response(ET.tostring(root, encoding="utf-8", xml_declaration=True), media_type="application/xml")


@app.get("/robots.txt", response_class=PlainTextResponse)
async def robots():
    if not settings.site_indexing_enabled:
        return "User-agent: *\nDisallow: /\n"
    return f"User-agent: *\nAllow: /\nDisallow: /api/\nSitemap: {site_url('/sitemap.xml')}\n"


@app.get("/favicon.ico", include_in_schema=False)
async def favicon():
    return FileResponse(STATIC_DIR / "favi.png", media_type="image/png")


@app.get("/health")
async def health():
    pool = await get_pool()
    await pool.fetchval("SELECT 1")
    return {"status": "ok"}


@app.exception_handler(404)
async def not_found(request: Request, _):
    return templates.TemplateResponse(
        "404.html",
        base_context(request, meta_title=f"Страница не найдена — {settings.site_name}", noindex=True),
        status_code=404,
    )
