package com.tasteby.controller;

import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.tasteby.domain.Restaurant;
import com.tasteby.security.AuthUtil;
import com.tasteby.service.CacheService;
import com.tasteby.service.GeocodingService;
import com.tasteby.service.RestaurantService;
import jakarta.annotation.PreDestroy;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;

import java.net.URI;
import java.net.URLDecoder;
import java.net.URLEncoder;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.util.*;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.ThreadLocalRandom;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

@RestController
@RequestMapping("/api/restaurants")
public class RestaurantController {

    private static final Logger log = LoggerFactory.getLogger(RestaurantController.class);

    private final RestaurantService restaurantService;
    private final GeocodingService geocodingService;
    private final CacheService cache;
    private final ObjectMapper objectMapper;
    private final ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();

    public RestaurantController(RestaurantService restaurantService, GeocodingService geocodingService, CacheService cache, ObjectMapper objectMapper) {
        this.restaurantService = restaurantService;
        this.geocodingService = geocodingService;
        this.cache = cache;
        this.objectMapper = objectMapper;
    }

    // #290 — Bean 종료 시 virtual thread executor를 정리하여 리소스 누수 방지.
    @PreDestroy
    public void shutdownExecutor() {
        executor.shutdown();
    }

    @GetMapping
    public List<Restaurant> list(
            @RequestParam(defaultValue = "100") int limit,
            @RequestParam(defaultValue = "0") int offset,
            @RequestParam(required = false) String cuisine,
            @RequestParam(required = false) String region,
            @RequestParam(required = false) String channel) {
        if (limit > 500) limit = 500;
        String key = cache.makeKey("restaurants", "l=" + limit, "o=" + offset,
                "c=" + cuisine, "r=" + region, "ch=" + channel);
        String cached = cache.getRaw(key);
        if (cached != null) {
            try {
                return objectMapper.readValue(cached, new TypeReference<List<Restaurant>>() {});
            } catch (Exception e) { log.warn("Cache deserialize failed, evicting: {}", e.getMessage()); cache.del(key); }
        }
        var result = restaurantService.findAll(limit, offset, cuisine, region, channel);
        cache.set(key, result);
        return result;
    }

    @GetMapping("/{id}")
    public Restaurant get(@PathVariable String id) {
        String key = cache.makeKey("restaurant", id);
        String cached = cache.getRaw(key);
        if (cached != null) {
            try {
                return objectMapper.readValue(cached, Restaurant.class);
            } catch (Exception e) { log.warn("Cache deserialize failed, evicting: {}", e.getMessage()); cache.del(key); }
        }
        var r = restaurantService.findById(id);
        if (r == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Restaurant not found");
        cache.set(key, r);
        return r;
    }

    @PutMapping("/{id}")
    public Map<String, Object> update(@PathVariable String id, @RequestBody Map<String, Object> body) {
        AuthUtil.requireAdmin();
        var r = restaurantService.findById(id);
        if (r == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Restaurant not found");

        // Re-geocode if name or address changed
        String newName = (String) body.get("name");
        String newAddress = (String) body.get("address");
        boolean nameChanged = newName != null && !newName.equals(r.getName());
        boolean addressChanged = newAddress != null && !newAddress.equals(r.getAddress());
        if (nameChanged || addressChanged) {
            String geoName = newName != null ? newName : r.getName();
            String geoAddr = newAddress != null ? newAddress : r.getAddress();
            var geo = geocodingService.geocodeRestaurant(geoName, geoAddr);
            if (geo != null) {
                body.put("latitude", geo.get("latitude"));
                body.put("longitude", geo.get("longitude"));
                body.put("google_place_id", geo.get("google_place_id"));
                if (geo.containsKey("formatted_address")) {
                    body.put("address", geo.get("formatted_address"));
                }
                if (geo.containsKey("rating")) body.put("rating", geo.get("rating"));
                if (geo.containsKey("rating_count")) body.put("rating_count", geo.get("rating_count"));
                if (geo.containsKey("phone")) body.put("phone", geo.get("phone"));
                if (geo.containsKey("business_status")) body.put("business_status", geo.get("business_status"));

                // formatted_address에서 region 파싱 (예: "대한민국 서울특별시 강남구 ..." → "한국|서울|강남구")
                String addr = (String) geo.get("formatted_address");
                if (addr != null) {
                    body.put("region", GeocodingService.parseRegionFromAddress(addr));
                }
            }
        }

        restaurantService.update(id, body);
        cache.flush();
        var updated = restaurantService.findById(id);
        return Map.of("ok", true, "restaurant", updated);
    }


    @DeleteMapping("/{id}")
    public Map<String, Object> delete(@PathVariable String id) {
        AuthUtil.requireAdmin();
        var r = restaurantService.findById(id);
        if (r == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Restaurant not found");
        restaurantService.delete(id);
        cache.flush();
        return Map.of("ok", true);
    }

    /** 단건 테이블링 URL 검색 */
    @GetMapping("/{id}/tabling-search")
    public List<Map<String, Object>> tablingSearch(@PathVariable String id) {
        AuthUtil.requireAdmin();
        var r = restaurantService.findById(id);
        if (r == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND);

        try {
            return searchTabling(r.getName());
        } catch (Exception e) {
            log.error("[TABLING] Search failed for '{}': {}", r.getName(), e.getMessage());
            throw new ResponseStatusException(HttpStatus.BAD_GATEWAY, "Search failed: " + e.getMessage());
        }
    }

    /** 테이블링 미연결 식당 목록 */
    @GetMapping("/tabling-pending")
    public Map<String, Object> tablingPending() {
        AuthUtil.requireAdmin();
        var list = restaurantService.findWithoutTabling();
        var summary = list.stream()
                .map(r -> Map.of("id", (Object) r.getId(), "name", (Object) r.getName()))
                .toList();
        return Map.of("count", list.size(), "restaurants", summary);
    }

    /** 벌크 테이블링 검색 (SSE) */
    @PostMapping("/bulk-tabling")
    public SseEmitter bulkTabling() {
        AuthUtil.requireAdmin();
        SseEmitter emitter = new SseEmitter(600_000L);

        executor.execute(() -> {
            try {
                var restaurants = restaurantService.findWithoutTabling();
                int total = restaurants.size();
                emit(emitter, Map.of("type", "start", "total", total));

                if (total == 0) {
                    emit(emitter, Map.of("type", "complete", "total", 0, "linked", 0, "notFound", 0));
                    emitter.complete();
                    return;
                }

                int linked = 0;
                int notFound = 0;

                for (int i = 0; i < total; i++) {
                    var r = restaurants.get(i);
                    emit(emitter, Map.of("type", "processing", "current", i + 1,
                            "total", total, "name", r.getName()));

                    try {
                        var results = searchTabling(r.getName());
                        if (!results.isEmpty()) {
                            String url = String.valueOf(results.get(0).get("url"));
                            String title = String.valueOf(results.get(0).get("title"));
                            if (isNameSimilar(r.getName(), title)) {
                                restaurantService.update(r.getId(), Map.of("tabling_url", url));
                                linked++;
                                emit(emitter, Map.of("type", "done", "current", i + 1,
                                        "name", r.getName(), "url", url, "title", title));
                            } else {
                                restaurantService.update(r.getId(), Map.of("tabling_url", "NONE"));
                                notFound++;
                                log.info("[TABLING] Name mismatch: '{}' vs '{}', skipping", r.getName(), title);
                                emit(emitter, Map.of("type", "notfound", "current", i + 1,
                                        "name", r.getName(), "reason", "이름 불일치: " + title));
                            }
                        } else {
                            restaurantService.update(r.getId(), Map.of("tabling_url", "NONE"));
                            notFound++;
                            emit(emitter, Map.of("type", "notfound", "current", i + 1,
                                    "name", r.getName()));
                        }
                    } catch (Exception e) {
                        notFound++;
                        emit(emitter, Map.of("type", "error", "current", i + 1,
                                "name", r.getName(), "message", e.getMessage()));
                    }

                    // 랜덤 딜레이 (2~5초)
                    int delay = ThreadLocalRandom.current().nextInt(2000, 5001);
                    log.info("[TABLING] Waiting {}ms before next search...", delay);
                    Thread.sleep(delay);
                }

                cache.flush();
                emit(emitter, Map.of("type", "complete", "total", total, "linked", linked, "notFound", notFound));
                emitter.complete();
            } catch (Exception e) {
                log.error("[TABLING] Bulk search error", e);
                emitter.completeWithError(e);
            }
        });

        return emitter;
    }

    /** 테이블링 URL 저장 */
    @PutMapping("/{id}/tabling-url")
    public Map<String, Object> setTablingUrl(@PathVariable String id, @RequestBody Map<String, String> body) {
        AuthUtil.requireAdmin();
        var r = restaurantService.findById(id);
        if (r == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND);
        String url = body.get("tabling_url");
        // #290 — javascript:/외부 악성 URL 차단. 빈 문자열은 매핑 해제로 허용.
        if (url != null && !url.isBlank() && !url.startsWith("https://tabling.co.kr/")) {
            throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "테이블링 URL은 https://tabling.co.kr/ 만 허용");
        }
        restaurantService.update(id, Map.of("tabling_url", url != null ? url : ""));
        cache.flush();
        return Map.of("ok", true);
    }

    /** 테이블링/캐치테이블 매핑 초기화 */
    @DeleteMapping("/reset-tabling")
    public Map<String, Object> resetTabling() {
        AuthUtil.requireAdmin();
        restaurantService.resetTablingUrls();
        cache.flush();
        return Map.of("ok", true);
    }

    @DeleteMapping("/reset-catchtable")
    public Map<String, Object> resetCatchtable() {
        AuthUtil.requireAdmin();
        restaurantService.resetCatchtableUrls();
        cache.flush();
        return Map.of("ok", true);
    }

    /** 단건 캐치테이블 URL 검색 */
    @GetMapping("/{id}/catchtable-search")
    public List<Map<String, Object>> catchtableSearch(@PathVariable String id) {
        AuthUtil.requireAdmin();
        var r = restaurantService.findById(id);
        if (r == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND);
        try {
            return searchCatchtable(r.getName());
        } catch (Exception e) {
            log.error("[CATCHTABLE] Search failed for '{}': {}", r.getName(), e.getMessage());
            throw new ResponseStatusException(HttpStatus.BAD_GATEWAY, "Search failed: " + e.getMessage());
        }
    }

    /** 캐치테이블 미연결 식당 목록 */
    @GetMapping("/catchtable-pending")
    public Map<String, Object> catchtablePending() {
        AuthUtil.requireAdmin();
        var list = restaurantService.findWithoutCatchtable();
        var summary = list.stream()
                .map(r -> Map.of("id", (Object) r.getId(), "name", (Object) r.getName()))
                .toList();
        return Map.of("count", list.size(), "restaurants", summary);
    }

    /** 벌크 캐치테이블 검색 (SSE) */
    @PostMapping("/bulk-catchtable")
    public SseEmitter bulkCatchtable() {
        AuthUtil.requireAdmin();
        SseEmitter emitter = new SseEmitter(600_000L);

        executor.execute(() -> {
            try {
                var restaurants = restaurantService.findWithoutCatchtable();
                int total = restaurants.size();
                emit(emitter, Map.of("type", "start", "total", total));

                if (total == 0) {
                    emit(emitter, Map.of("type", "complete", "total", 0, "linked", 0, "notFound", 0));
                    emitter.complete();
                    return;
                }

                int linked = 0;
                int notFound = 0;

                for (int i = 0; i < total; i++) {
                    var r = restaurants.get(i);
                    emit(emitter, Map.of("type", "processing", "current", i + 1,
                            "total", total, "name", r.getName()));

                    try {
                        var results = searchCatchtable(r.getName());
                        if (!results.isEmpty()) {
                            String url = String.valueOf(results.get(0).get("url"));
                            String title = String.valueOf(results.get(0).get("title"));
                            if (isNameSimilar(r.getName(), title)) {
                                restaurantService.update(r.getId(), Map.of("catchtable_url", url));
                                linked++;
                                emit(emitter, Map.of("type", "done", "current", i + 1,
                                        "name", r.getName(), "url", url, "title", title));
                            } else {
                                restaurantService.update(r.getId(), Map.of("catchtable_url", "NONE"));
                                notFound++;
                                log.info("[CATCHTABLE] Name mismatch: '{}' vs '{}', skipping", r.getName(), title);
                                emit(emitter, Map.of("type", "notfound", "current", i + 1,
                                        "name", r.getName(), "reason", "이름 불일치: " + title));
                            }
                        } else {
                            restaurantService.update(r.getId(), Map.of("catchtable_url", "NONE"));
                            notFound++;
                            emit(emitter, Map.of("type", "notfound", "current", i + 1,
                                    "name", r.getName()));
                        }
                    } catch (Exception e) {
                        notFound++;
                        emit(emitter, Map.of("type", "error", "current", i + 1,
                                "name", r.getName(), "message", e.getMessage()));
                    }

                    int delay = ThreadLocalRandom.current().nextInt(2000, 5001);
                    log.info("[CATCHTABLE] Waiting {}ms before next search...", delay);
                    Thread.sleep(delay);
                }

                cache.flush();
                emit(emitter, Map.of("type", "complete", "total", total, "linked", linked, "notFound", notFound));
                emitter.complete();
            } catch (Exception e) {
                log.error("[CATCHTABLE] Bulk search error", e);
                emitter.completeWithError(e);
            }
        });

        return emitter;
    }

    /** 캐치테이블 URL 저장 */
    @PutMapping("/{id}/catchtable-url")
    public Map<String, Object> setCatchtableUrl(@PathVariable String id, @RequestBody Map<String, String> body) {
        AuthUtil.requireAdmin();
        var r = restaurantService.findById(id);
        if (r == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND);
        String url = body.get("catchtable_url");
        // #290 — javascript:/외부 악성 URL 차단. 빈 문자열은 매핑 해제로 허용.
        if (url != null && !url.isBlank()
                && !url.startsWith("https://app.catchtable.co.kr/")
                && !url.startsWith("https://www.catchtable.co.kr/")) {
            throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "캐치테이블 URL은 https://(app|www).catchtable.co.kr/ 만 허용");
        }
        restaurantService.update(id, Map.of("catchtable_url", url != null ? url : ""));
        cache.flush();
        return Map.of("ok", true);
    }

    @GetMapping("/{id}/videos")
    public List<Map<String, Object>> videos(@PathVariable String id) {
        String key = cache.makeKey("restaurant_videos", id);
        String cached = cache.getRaw(key);
        if (cached != null) {
            try {
                return objectMapper.readValue(cached, new TypeReference<List<Map<String, Object>>>() {});
            } catch (Exception e) { log.warn("Cache deserialize failed, evicting: {}", e.getMessage()); cache.del(key); }
        }
        var r = restaurantService.findById(id);
        if (r == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Restaurant not found");
        var result = restaurantService.findVideoLinks(id);
        cache.set(key, result);
        return result;
    }

    // ─── DuckDuckGo HTML search helpers ─────────────────────────────────

    private static final HttpClient httpClient = HttpClient.newBuilder()
            .followRedirects(HttpClient.Redirect.NORMAL)
            .build();

    private static final Pattern DDG_RESULT_PATTERN = Pattern.compile(
            "<a[^>]+class=\"result__a\"[^>]+href=\"([^\"]+)\"[^>]*>(.*?)</a>",
            Pattern.DOTALL
    );

    /**
     * DuckDuckGo HTML 검색을 통해 특정 사이트의 URL을 찾는다.
     * html.duckduckgo.com은 서버사이드 렌더링이라 봇 판정 없이 HTTP 요청만으로 결과를 파싱할 수 있다.
     */
    private List<Map<String, Object>> searchDuckDuckGo(String query, String... urlPatterns) throws Exception {
        String encoded = URLEncoder.encode(query, StandardCharsets.UTF_8);
        String searchUrl = "https://html.duckduckgo.com/html/?q=" + encoded;
        log.info("[DDG] Searching: {}", query);

        HttpRequest request = HttpRequest.newBuilder()
                .uri(URI.create(searchUrl))
                .header("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36")
                .header("Accept", "text/html,application/xhtml+xml")
                .header("Accept-Language", "ko-KR,ko;q=0.9")
                .GET()
                .build();

        HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
        String html = response.body();

        List<Map<String, Object>> results = new ArrayList<>();
        Set<String> seen = new HashSet<>();
        Matcher matcher = DDG_RESULT_PATTERN.matcher(html);

        while (matcher.find() && results.size() < 5) {
            String href = matcher.group(1);
            String title = matcher.group(2).replaceAll("<[^>]+>", "").trim();

            // DDG 링크에서 실제 URL 추출 (uddg 파라미터)
            String actualUrl = extractDdgUrl(href);
            if (actualUrl == null) continue;

            boolean matches = false;
            for (String pattern : urlPatterns) {
                if (actualUrl.contains(pattern)) {
                    matches = true;
                    break;
                }
            }
            if (matches && !seen.contains(actualUrl)) {
                seen.add(actualUrl);
                results.add(Map.of("title", title, "url", actualUrl));
            }
        }

        log.info("[DDG] Found {} results for '{}'", results.size(), query);
        return results;
    }

    /** DDG 리다이렉트 URL에서 실제 URL 추출 */
    private String extractDdgUrl(String ddgHref) {
        try {
            // //duckduckgo.com/l/?uddg=ENCODED_URL&rut=...
            if (ddgHref.contains("uddg=")) {
                String uddgParam = ddgHref.substring(ddgHref.indexOf("uddg=") + 5);
                int ampIdx = uddgParam.indexOf('&');
                if (ampIdx > 0) uddgParam = uddgParam.substring(0, ampIdx);
                return URLDecoder.decode(uddgParam, StandardCharsets.UTF_8);
            }
            // 직접 URL인 경우
            if (ddgHref.startsWith("http")) return ddgHref;
        } catch (Exception e) {
            log.debug("[DDG] Failed to extract URL from: {}", ddgHref);
        }
        return null;
    }

    private List<Map<String, Object>> searchTabling(String restaurantName) throws Exception {
        return searchDuckDuckGo(
                "site:tabling.co.kr " + restaurantName,
                "tabling.co.kr/restaurant/", "tabling.co.kr/place/"
        );
    }

    private List<Map<String, Object>> searchCatchtable(String restaurantName) throws Exception {
        return searchDuckDuckGo(
                "site:app.catchtable.co.kr " + restaurantName,
                "catchtable.co.kr/dining/", "catchtable.co.kr/shop/"
        );
    }

    /**
     * 식당 이름과 검색 결과 제목의 유사도 검사.
     * 한쪽 이름이 다른쪽에 포함되거나, 공통 글자 비율이 40% 이상이면 유사하다고 판단.
     */
    private boolean isNameSimilar(String restaurantName, String resultTitle) {
        String a = normalize(restaurantName);
        String b = normalize(resultTitle);
        if (a.isEmpty() || b.isEmpty()) return false;

        // 포함 관계 체크
        if (a.contains(b) || b.contains(a)) return true;

        // 공통 문자 비율 (Jaccard-like)
        var setA = a.chars().boxed().collect(java.util.stream.Collectors.toSet());
        var setB = b.chars().boxed().collect(java.util.stream.Collectors.toSet());
        long common = setA.stream().filter(setB::contains).count();
        double ratio = (double) common / Math.max(setA.size(), setB.size());
        return ratio >= 0.4;
    }

    private String normalize(String s) {
        if (s == null) return "";
        return s.replaceAll("[\\s·\\-_()（）\\[\\]【】]", "").toLowerCase();
    }

    private void emit(SseEmitter emitter, Map<String, Object> data) {
        try {
            emitter.send(SseEmitter.event().data(objectMapper.writeValueAsString(data)));
        } catch (Exception e) {
            log.debug("SSE emit error: {}", e.getMessage());
        }
    }
}
