"""Integration-тесты ET-011 download-эндпоинта.

Покрывает test-plan: IT-01..IT-07 (+ IT-05 license-фильтр).
"""
from __future__ import annotations

import json
import os
import sqlite3
import urllib.parse

import pytest
from fastapi import FastAPI
from httpx import ASGITransport, AsyncClient
from lxml import etree as lxml_et
from shapely import wkb as shp_wkb
from shapely.geometry import LineString

from src.api.gps_tracks.db import init_db, open_db, upsert_track
from src.api.gps_tracks.dedup import compute_dedup_key
from src.api.gps_tracks.endpoint import create_gps_router
from src.api.gps_tracks.models import TrackInsert


GPX_NS = "http://www.topografix.com/GPX/1/1"

_FIXTURES_DIR = os.path.join(
    os.path.dirname(__file__), "..", "fixtures", "gpx-1.1"
)
_GPX_XSD_PATH = os.path.abspath(os.path.join(_FIXTURES_DIR, "gpx.xsd"))


# ─── Helpers ──────────────────────────────────────────────────────────────

def _make_track(
    *,
    external_id: str = "T1",
    source_id: str = "osm",
    name: str | None = "Test trail",
    description: str | None = None,
    activity_type: str | None = "enduro",
    user: str | None = None,
    created_at: str | None = "2024-05-12T10:00:00Z",
    n_points: int = 10,
    length_m: float = 5000.0,
    external_url: str | None = "https://www.openstreetmap.org/way/1",
    source_priority: int = 50,
    base_lon: float = 37.60,
    base_lat: float = 55.74,
) -> TrackInsert:
    """Создаёт TrackInsert с реальной WKB-геометрией."""
    coords = [(base_lon + i * 0.001, base_lat + i * 0.001) for i in range(n_points)]
    line = LineString(coords)
    geom_wkb = shp_wkb.dumps(line)
    min_lon = min(c[0] for c in coords)
    max_lon = max(c[0] for c in coords)
    min_lat = min(c[1] for c in coords)
    max_lat = max(c[1] for c in coords)
    return TrackInsert(
        external_id=external_id,
        source_id=source_id,
        external_url=external_url,
        name=name,
        description=description,
        activity_type=activity_type,
        user=user,
        created_at=created_at,
        length_m=length_m,
        points_count=n_points,
        geom_wkb=geom_wkb,
        min_lon=min_lon,
        min_lat=min_lat,
        max_lon=max_lon,
        max_lat=max_lat,
        tags=[],
        source_priority=source_priority,
    )


def _insert_track(conn: sqlite3.Connection, track: TrackInsert) -> int:
    """Вставляет трек и возвращает его id."""
    dedup = compute_dedup_key(
        (track.min_lon, track.min_lat, track.max_lon, track.max_lat),
        {"length_m": track.length_m, "created_at": track.created_at},
    )
    upsert_track(conn, track, dedup, source_priority=track.source_priority or 50)
    cur = conn.cursor()
    cur.execute("SELECT id FROM tracks WHERE dedup_key = ?", (dedup,))
    return cur.fetchone()["id"]


def _make_app(db_path: str, sources_config_path: str | None = None) -> FastAPI:
    app = FastAPI()
    router = create_gps_router(db_path, sources_config_path)
    app.include_router(router)
    return app


def _config_with(sources: dict[str, bool], tmp_path) -> str:
    """Создаёт временный gps_sources.yaml с заданными download_allowed."""
    lines = ["sources:"]
    for sid, allowed in sources.items():
        lines.append(f"  - id: {sid}")
        lines.append(f"    name: \"{sid}\"")
        lines.append("    enabled: true")
        lines.append(f"    license_adr: \"docs/test-{sid}.md\"")
        lines.append(f"    base_url: \"https://example.com/{sid}\"")
        lines.append(f"    download_allowed: {'true' if allowed else 'false'}")
    path = tmp_path / "gps_sources.yaml"
    path.write_text("\n".join(lines), encoding="utf-8")
    return str(path)


@pytest.fixture
def osm_db(tmp_path):
    """БД с одним OSM-треком из 10 точек."""
    db_path = str(tmp_path / "osm.sqlite")
    conn = open_db(db_path)
    init_db(conn)
    track_id = _insert_track(conn, _make_track())
    conn.close()
    return db_path, track_id


@pytest.fixture
def osm_db_app(osm_db, tmp_path):
    """App, где OSM разрешён для скачивания (default)."""
    db_path, track_id = osm_db
    cfg = _config_with({"osm": True, "wikiloc": False}, tmp_path)
    return _make_app(db_path, cfg), track_id


# ─── IT-01: happy path ─────────────────────────────────────────────────────

@pytest.mark.asyncio
async def test_it01_download_happy_path(osm_db_app):
    """IT-01: GET /api/gps-tracks/{id}/download → 200 + правильные хедеры."""
    app, track_id = osm_db_app
    async with AsyncClient(
        transport=ASGITransport(app=app), base_url="http://test"
    ) as client:
        resp = await client.get(f"/api/gps-tracks/{track_id}/download")

    assert resp.status_code == 200
    assert resp.headers["content-type"].startswith("application/gpx+xml")
    cd = resp.headers["content-disposition"]
    assert "attachment" in cd
    assert "filename*=UTF-8''" in cd
    assert resp.text.startswith("<?xml")
    assert "<gpx" in resp.text
    assert 'version="1.1"' in resp.text
    assert resp.text.count("<trkpt") == 10


# ─── IT-02: 404 ────────────────────────────────────────────────────────────

@pytest.mark.asyncio
async def test_it02_track_not_found(osm_db_app):
    app, _ = osm_db_app
    async with AsyncClient(
        transport=ASGITransport(app=app), base_url="http://test"
    ) as client:
        resp = await client.get("/api/gps-tracks/99999999/download")
    assert resp.status_code == 404
    detail = resp.json().get("detail", "")
    assert "not_found" in str(detail).lower() or "track_not_found" in str(detail)


# ─── IT-03: невалидный format ──────────────────────────────────────────────

@pytest.mark.asyncio
async def test_it03_invalid_format(osm_db_app):
    app, track_id = osm_db_app
    async with AsyncClient(
        transport=ASGITransport(app=app), base_url="http://test"
    ) as client:
        resp = await client.get(
            f"/api/gps-tracks/{track_id}/download", params={"format": "fit"}
        )
    assert resp.status_code == 400


@pytest.mark.asyncio
async def test_it03_explicit_gpx_format_ok(osm_db_app):
    """format=gpx синонимично default'у."""
    app, track_id = osm_db_app
    async with AsyncClient(
        transport=ASGITransport(app=app), base_url="http://test"
    ) as client:
        resp = await client.get(
            f"/api/gps-tracks/{track_id}/download", params={"format": "gpx"}
        )
    assert resp.status_code == 200


# ─── IT-04: 413 patho-трек ─────────────────────────────────────────────────

@pytest.mark.asyncio
async def test_it04_track_too_large(tmp_path):
    """IT-04: points_count > 200000 → 413 (без чтения geom)."""
    db_path = str(tmp_path / "huge.sqlite")
    conn = open_db(db_path)
    init_db(conn)

    # Используем upsert обычным путём, потом подменим points_count
    track_id = _insert_track(conn, _make_track(name="Huge"))
    cur = conn.cursor()
    cur.execute("UPDATE tracks SET points_count = 300000 WHERE id = ?", (track_id,))
    conn.commit()
    conn.close()

    cfg = _config_with({"osm": True}, tmp_path)
    app = _make_app(db_path, cfg)
    async with AsyncClient(
        transport=ASGITransport(app=app), base_url="http://test"
    ) as client:
        resp = await client.get(f"/api/gps-tracks/{track_id}/download")

    assert resp.status_code == 413


# ─── IT-05: license-фильтр (403) ───────────────────────────────────────────

@pytest.mark.asyncio
async def test_it05_source_forbidden_403(tmp_path):
    """IT-05: трек только с wikiloc → 403 если wikiloc нет в whitelist."""
    db_path = str(tmp_path / "wikiloc.sqlite")
    conn = open_db(db_path)
    init_db(conn)
    track_id = _insert_track(
        conn,
        _make_track(
            external_id="W1",
            source_id="wikiloc",
            external_url="https://www.wikiloc.com/abc",
        ),
    )
    conn.close()

    # Whitelist только osm → wikiloc запрещён
    cfg = _config_with({"osm": True, "wikiloc": False}, tmp_path)
    app = _make_app(db_path, cfg)
    async with AsyncClient(
        transport=ASGITransport(app=app), base_url="http://test"
    ) as client:
        resp = await client.get(f"/api/gps-tracks/{track_id}/download")

    assert resp.status_code == 403
    body = resp.json()
    # ADR-015 §G: одноуровневый контракт через JSONResponse в endpoint.py
    # (см. P2-01 в 12-review.md). Раньше FastAPI оборачивал detail-dict
    # в {"detail": {...}}; сейчас body == {"detail": "...", "external_urls": [...]}.
    assert body.get("detail") == "source_forbidden"
    assert body.get("external_urls") == ["https://www.wikiloc.com/abc"]


@pytest.mark.asyncio
async def test_it05_dual_source_with_osm_passes(tmp_path):
    """ADR-015 §B1: ANY-rule — трек с sources=[osm, wikiloc] разрешён."""
    db_path = str(tmp_path / "dual.sqlite")
    conn = open_db(db_path)
    init_db(conn)

    # Создаём трек один раз как osm, затем upsert-мерж с wikiloc
    t1 = _make_track(
        external_id="X1", source_id="osm",
        external_url="https://www.openstreetmap.org/way/1",
    )
    t2 = _make_track(
        external_id="X1", source_id="wikiloc",
        external_url="https://www.wikiloc.com/x",
        source_priority=70,
    )
    _insert_track(conn, t1)
    track_id = _insert_track(conn, t2)
    # Проверяем, что записалось два source'а
    cur = conn.cursor()
    cur.execute("SELECT sources_json FROM tracks WHERE id = ?", (track_id,))
    sources = json.loads(cur.fetchone()["sources_json"])
    assert "osm" in sources and "wikiloc" in sources
    conn.close()

    cfg = _config_with({"osm": True, "wikiloc": False}, tmp_path)
    app = _make_app(db_path, cfg)
    async with AsyncClient(
        transport=ASGITransport(app=app), base_url="http://test"
    ) as client:
        resp = await client.get(f"/api/gps-tracks/{track_id}/download")

    assert resp.status_code == 200


# ─── IT-06: UTF-8 имя ──────────────────────────────────────────────────────

@pytest.mark.asyncio
async def test_it06_utf8_filename_in_cd(tmp_path):
    db_path = str(tmp_path / "ru.sqlite")
    conn = open_db(db_path)
    init_db(conn)
    track_id = _insert_track(
        conn,
        _make_track(name="По грязи к Чёрному озеру"),
    )
    conn.close()

    cfg = _config_with({"osm": True}, tmp_path)
    app = _make_app(db_path, cfg)
    async with AsyncClient(
        transport=ASGITransport(app=app), base_url="http://test"
    ) as client:
        resp = await client.get(f"/api/gps-tracks/{track_id}/download")

    assert resp.status_code == 200
    cd = resp.headers["content-disposition"]
    assert "filename*=UTF-8''" in cd
    # Декодируем RFC 5987 часть
    star = cd.split("filename*=UTF-8''", 1)[1]
    encoded = star.split(";", 1)[0].strip()
    decoded = urllib.parse.unquote(encoded, encoding="utf-8")
    assert decoded == "По грязи к Чёрному озеру.gpx"
    # ASCII fallback — без кириллицы (проверим, что filename="..." есть)
    assert 'filename="' in cd
    plain = cd.split('filename="', 1)[1].split('"', 1)[0]
    assert all(ord(c) < 128 for c in plain)


# ─── IT-07: валидация GPX по XSD ───────────────────────────────────────────

@pytest.mark.asyncio
async def test_it07_response_validates_against_xsd(osm_db_app):
    if not os.path.exists(_GPX_XSD_PATH):
        pytest.skip("GPX XSD not present")
    app, track_id = osm_db_app
    async with AsyncClient(
        transport=ASGITransport(app=app), base_url="http://test"
    ) as client:
        resp = await client.get(f"/api/gps-tracks/{track_id}/download")
    assert resp.status_code == 200

    schema = lxml_et.XMLSchema(lxml_et.parse(_GPX_XSD_PATH))
    doc = lxml_et.fromstring(resp.content)
    schema.assertValid(doc)


# ─── IT-08: регрессия — существующие эндпоинты живы ───────────────────────

@pytest.mark.asyncio
async def test_it08_existing_endpoints_smoke(osm_db_app):
    """IT-08: добавление download не сломало /api/gps-tracks и /health."""
    app, _ = osm_db_app
    async with AsyncClient(
        transport=ASGITransport(app=app), base_url="http://test"
    ) as client:
        resp_bbox = await client.get(
            "/api/gps-tracks", params={"bbox": "37.5,55.7,37.9,55.9"}
        )
        resp_health = await client.get("/api/gps-tracks/health")

    assert resp_bbox.status_code == 200
    body = resp_bbox.json()
    assert body["type"] == "FeatureCollection"
    assert isinstance(body["features"], list)

    assert resp_health.status_code == 200
    health = resp_health.json()
    assert health["status"] == "ok"
    assert "tracks_total" in health


# ─── Дополнительно: проверка default-deny при отсутствии конфига ──────────

@pytest.mark.asyncio
async def test_default_deny_without_config(tmp_path):
    """Без sources_config_path whitelist = {osm} (см. ADR-015 §F)."""
    db_path = str(tmp_path / "noconfig.sqlite")
    conn = open_db(db_path)
    init_db(conn)
    # OSM трек — должен пройти даже без конфига
    osm_id = _insert_track(conn, _make_track(source_id="osm"))
    # Wikiloc трек в другом регионе — должен быть отдельной записью с другим
    # dedup_key и запрещён к скачиванию.
    wiki_id = _insert_track(
        conn,
        _make_track(
            external_id="W1",
            source_id="wikiloc",
            external_url="https://www.wikiloc.com/y",
            base_lon=40.0,
            base_lat=50.0,
            created_at="2025-01-01T00:00:00Z",
            length_m=8888.0,
        ),
    )
    conn.close()

    # Sanity: треки должны быть разными записями
    assert osm_id != wiki_id, (
        "test setup: tracks merged into one record via dedup_key"
    )

    app = _make_app(db_path, sources_config_path=None)
    async with AsyncClient(
        transport=ASGITransport(app=app), base_url="http://test"
    ) as client:
        r_osm = await client.get(f"/api/gps-tracks/{osm_id}/download")
        r_wiki = await client.get(f"/api/gps-tracks/{wiki_id}/download")

    assert r_osm.status_code == 200
    assert r_wiki.status_code == 403
