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.dto.RestaurantUpdateDTO;
import com.tasteby.service.CacheService;
import com.tasteby.service.GeocodingService;
import com.tasteby.service.RestaurantService;
import com.tasteby.service.WebSearchService;
import jakarta.validation.Valid;
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.util.*;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.ThreadLocalRandom;

@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 WebSearchService webSearch;
    private final ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();

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

    // #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,
            @RequestParam(name = "min_mentions", required = false) Integer minMentions) {
        if (limit > 500) limit = 500;
        String key = cache.makeKey("restaurants", "l=" + limit, "o=" + offset,
                "c=" + cuisine, "r=" + region, "ch=" + channel, "mm=" + minMentions);
        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, minMentions, false);
        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, @Valid @RequestBody RestaurantUpdateDTO dto) {
        AuthUtil.requireAdmin();
        var r = restaurantService.findById(id);
        if (r == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Restaurant not found");

        // #358 — DTO → Map (null 제외). 화이트리스트는 DTO 필드 자체로 표현.
        Map<String, Object> sanitized = dto.toFieldMap();

        // Re-geocode if name or address changed
        String newName = (String) sanitized.get("name");
        String newAddress = (String) sanitized.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) {
                sanitized.put("latitude", geo.get("latitude"));
                sanitized.put("longitude", geo.get("longitude"));
                sanitized.put("google_place_id", geo.get("google_place_id"));
                if (geo.containsKey("formatted_address")) {
                    sanitized.put("address", geo.get("formatted_address"));
                }
                if (geo.containsKey("rating")) sanitized.put("rating", geo.get("rating"));
                if (geo.containsKey("rating_count")) sanitized.put("rating_count", geo.get("rating_count"));
                if (geo.containsKey("phone")) sanitized.put("phone", geo.get("phone"));
                if (geo.containsKey("business_status")) sanitized.put("business_status", geo.get("business_status"));

                String addr = (String) geo.get("formatted_address");
                if (addr != null) {
                    sanitized.put("region", GeocodingService.parseRegionFromAddress(addr));
                }
            }
        }

        if (sanitized.isEmpty()) {
            // 허용 키가 하나도 없으면 no-op
            return Map.of("ok", true, "restaurant", r);
        }

        restaurantService.update(id, sanitized);
        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(10_800_000L); // 3h — 대량 백필 대응

        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 차단. 빈 문자열은 매핑 해제로 허용.
        // Naver/DDG 결과가 www.tabling.co.kr 형태로도 옴.
        if (url != null && !url.isBlank()
                && !url.startsWith("https://tabling.co.kr/")
                && !url.startsWith("https://www.tabling.co.kr/")) {
            throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "테이블링 URL은 https://(www.)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(10_800_000L); // 3h — 대량 백필 대응

        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,
            @RequestParam(name = "include_weak", defaultValue = "false") boolean includeWeak) {
        String key = cache.makeKey("restaurant_videos", id, includeWeak ? "all" : "strong");
        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, includeWeak);
        cache.set(key, result);
        return result;
    }

    // ─── 예약 사이트 URL 검색 (#357 WebSearchService: Naver primary + DDG fallback) ───

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

    private List<Map<String, Object>> searchCatchtable(String restaurantName) {
        // 실제 캐치테이블 URL은 /ct/shop/ 형식. 옛 /dining/ /shop/ 패턴은 매칭 실패.
        return webSearch.search(
                "site:app.catchtable.co.kr " + restaurantName,
                "catchtable.co.kr/ct/shop/", "catchtable.co.kr/ct/dining/"
        );
    }

    /**
     * 식당 이름과 검색 결과 제목의 유사도 검사.
     * 한쪽 이름이 다른쪽에 포함되거나, 공통 글자 비율이 40% 이상이면 유사하다고 판단.
     */
    /**
     * #348 — 한국어 자모 분해 + Sørensen-Dice bigram 유사도(임계값 0.45).
     * 짧은 한국어 이름에서 이전 Jaccard-like(set 비율) 방식보다 정확.
     */
    private boolean isNameSimilar(String restaurantName, String resultTitle) {
        return com.tasteby.util.HangulSimilarity.similarity(restaurantName, resultTitle) >= 0.45;
    }

    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());
        }
    }
}
