"""Material-only surplus cleanup using the user's authored, prepared input path.

Inventory is read before the tape. Only the authored item-search checkpoint
uses OCR. Detail buttons use item rules and precompiled CoordinateStore IDs.
No storage withdrawal, unlocking, paid CLI sale, or automatic sale replay.
"""
from copy import deepcopy
import json
import time
import unicodedata

from .bag_cleanup_plan import item_rule, material_rows, normalize_cleanup
from .errors import Cancelled, GameClientClosed, InputInterrupted, MiniError, ObservationUnavailable, require


def sale_stock(rows, name, category, keep):
    params, _ = normalize_cleanup(dict(kind="bag_cleanup", name=name, category=category, keep=keep))
    matches = [row for row in material_rows(rows) if (row["name"], row["category"]) == (name, category)]
    stock = matches[0] if matches else dict(inventory=0, inventory_locked=0)
    count, locked = stock["inventory"], stock["inventory_locked"]
    return dict(params, before_count=count, locked=locked,
                quantity=min(max(0, count-keep), count-locked))


def _text(value):
    # Keep '+', grade markers and punctuation: 목재 and 목재+ are different.
    return "".join(unicodedata.normalize("NFC", str(value)).split())


def matching_result_cells(name, entries, records):
    """Map exact current-frame names to precompiled cell IDs, never OCR clicks."""
    wanted = _text(name)
    found = []
    for role, row in records.items():
        if not role.startswith("bag.result.cell.") or not row["visible"]:
            continue
        roi = row["metadata"].get("label_roi", row["metadata"].get("normalized_roi"))
        if roi is None:
            continue
        tokens = []
        uncertain = False
        for text, box, score in entries:
            x, y = (box[0]+box[2])/2, (box[1]+box[3])/2
            if roi[0] <= x <= roi[2] and roi[1] <= y <= roi[3]:
                uncertain = uncertain or score < .6
                tokens.append((box[1], box[0], _text(text)))
        tokens.sort()
        values = [v[2] for v in tokens]
        # A material caption may wrap into multiple OCR rows. Never permit a
        # prefix/substring match or strip punctuation to force one candidate.
        if not uncertain and "".join(values) == wanted:
            found.append(role)
    return found


class PreparedBagPort:
    def __init__(self, service):
        self.s = service
        self._context = None

    def _stamp(self):
        runtime = self.runtime()
        return (getattr(self.s, "input_resume_generation", 0),
                getattr(self.s.window, "pid", None), getattr(self.s.window, "hwnd", None),
                runtime.coordinates.snapshot.generation if hasattr(runtime, "coordinates") else None)

    def begin_context(self):
        self.s.sync_input_generation(self.input_stamp())
        self._context = self._stamp()

    def input_stamp(self):
        from .automation import last_input_stamp
        return last_input_stamp()

    def current(self):
        return self._context is None or self._context == self._stamp()

    def runtime(self):
        runtime = self.s._ui_route_runtime()
        runtime.validate_prepared_window(self.s.window)
        return runtime

    def click(self, scene, role, *, state=None, **kwargs):
        target = self.runtime().prepared_ref(scene, role, **(state or {}))
        if self.s.input("click", target, guard=self.current, **kwargs) is False:
            raise InputInterrupted("가방정리 입력 보류 · 전송하지 않은 현재 단계 유지")

    def type_text(self, value):
        if self.s.input("bag_text", value, guard=self.current) is False:
            raise InputInterrupted("가방 검색어/수량 입력 보류")

    def validate_receipt(self, before_stamp):
        # Receipt fsync is intentionally before the native press. Re-check
        # ownership after that I/O, without refocusing or moving the pointer.
        self.s.check()
        self.s.window._check_input_activity()
        if (self.input_stamp() != before_stamp or not self.current()
                or not self.s.window.is_foreground()):
            raise InputInterrupted("가방정리 기록 저장 중 사용자 입력/전경 변경 · 처리 미전송")

    def open_search(self, name):
        # CommonFilter SearchBar's focusOnEnterKey listens on key-up and only
        # focuses TextInput. This avoids a guessed content-height-dependent
        # portrait textbox coordinate; Apply remains its own authored click.
        focus_key = self.runtime().prepared_bundle("bag.cleanup")["bag.reset"]["metadata"]["search_focus_key"]
        with self.s.coordinate_sequence():
            for role in ("bag.open", "bag.items"):
                self.click("bag.cleanup", role)
            rows = self.runtime().prepared_bundle("bag.cleanup")
            for step in rows["bag.categories.reset"]["metadata"]["scroll_steps"]:
                ref = self.runtime().prepared_ref("bag.cleanup", "bag.categories.reset")
                if self.s.input("scroll", (ref, step["delta"]), guard=self.current) is False:
                    raise InputInterrupted("가방 재료 탭 위치 복귀 보류")
            for role in ("bag.materials", "bag.search", "bag.reset"):
                self.click("bag.cleanup", role)
            if self.s.input("key", focus_key, guard=self.current) is False:
                raise InputInterrupted("가방 검색 입력 포커스 보류")
            self.type_text(name)
            self.click("bag.cleanup", "bag.apply")
            self.s.coordinate_step()

    def observe_item(self, name):
        from .focused_ocr import FocusedOCR
        from .route_geometry import crop_bounds
        if self.s.ocr is None:
            self.s.ocr = FocusedOCR()
        reported = float("-inf")
        while True:
            self.s.check()
            try:
                if not self.s.observation_foreground():
                    self.s.sleep(.5)
                    continue
                runtime = self.runtime()
                records = runtime.prepared_bundle("bag.cleanup")
                image = self.s.window.capture()
                roi = records["bag.result.roi"]["metadata"]["normalized_roi"]
                result = self.s.ocr.read(image, roi)
                bounds = crop_bounds(roi, image.size)
                entries = []
                texts, boxes, scores = (getattr(result, key, None) for key in ("txts", "boxes", "scores"))
                for text, box, score in zip([] if texts is None else texts,
                                            [] if boxes is None else boxes,
                                            [] if scores is None else scores):
                    xs, ys = [point[0] for point in box], [point[1] for point in box]
                    region = ((bounds[0]+min(xs))/image.width, (bounds[1]+min(ys))/image.height,
                              (bounds[0]+max(xs))/image.width, (bounds[1]+max(ys))/image.height)
                    entries.append((text, region, float(score)))
                matches = matching_result_cells(name, entries, records)
                if len(matches) == 1:
                    return matches[0]
            except ObservationUnavailable:
                pass
            if time.monotonic()-reported >= 15:
                self.s.status(name + " · 검색 결과의 정확한 재료 확인 중 · 판매 입력 없음")
                reported = time.monotonic()
            self.s.sleep(.8)

    def sell(self, item_role, quantity, before_sale, after_sale):
        policy = self.policy
        action, warning = policy["action"], policy["warning"]
        detail_state = dict(ordered_roles=tuple(policy["detail_roles"]))
        with self.s.coordinate_sequence():
            self.click("bag.cleanup", item_role)
            self.click("bag.detail", "bag." + action, state=detail_state)
            if action == "sell" and warning:
                self.click("bag.warning", "bag.warning.confirm")
            self.click("bag.sale", "bag.quantity")
            self.type_text(str(quantity))
            if action == "discard" and warning:
                # Discard's grade warning comes AFTER quantity selection;
                # the preceding quantity button has not removed anything yet.
                self.click("bag.sale", "bag.confirm")
                self.click("bag.warning", "bag.warning.confirm", before_send=before_sale)
            else:
                self.click("bag.sale", "bag.confirm", before_send=before_sale)
            after_sale()
            self.click("bag.cleanup", "bag.close")
            self.s.coordinate_step()

    def close_pending(self):
        # This is cancellation/error recovery, not the normal input tape. A
        # receipt must not blindly close an unrelated screen after manual work.
        from .source_common_observation import common_field_ready
        from .focused_ocr import FocusedOCR
        if self.s.ocr is None:
            self.s.ocr = FocusedOCR()
        while True:
            self.s.check()
            if not self.s.observation_foreground():
                self.s.sleep(.5)
                continue
            image = self.s.window.capture()
            if common_field_ready(self.s, image):
                return
            rows = self.runtime().prepared_bundle("bag.cleanup")
            roi = rows["bag.header"]["metadata"]["normalized_roi"]
            if _text(self.s.ocr.text(image, roi)) == "가방":
                self.begin_context()
                with self.s.coordinate_sequence():
                    self.click("bag.cleanup", "bag.close")
                    self.s.coordinate_step()
                return
            self.s.status("가방정리 마무리 화면 확인 중 · 판매 재전송 없음")
            self.s.sleep(.8)


class BagCleanup:
    def __init__(self, service, port=None):
        self.s = service
        self.port = port or PreparedBagPort(service)

    def _stock(self, params):
        while True:
            self.s.check()
            try:
                stamp = getattr(self.port, "input_stamp", lambda: None)
                before = stamp()
                rows = self.s.connector.inventory()
                self.s.check()
                after = stamp()
                if before != after:
                    self.s.sleep(.5)
                    continue
                self._stock_input_stamp = after
                return sale_stock(rows, params["name"], params["category"], params["keep"])
            except (Cancelled, GameClientClosed):
                raise
            except (ObservationUnavailable, MiniError):
                self.s.status("가방정리 재고 조회 대기 · 판매 입력 없음")
                self.s.sleep(1)

    def run(self, *, name, category, keep):
        params, label = normalize_cleanup(dict(kind="bag_cleanup", name=name, category=category, keep=keep))
        task = getattr(self.s, "_task_identity", None)
        signature = json.dumps(dict(params, task_identity=task), sort_keys=True)
        pending = deepcopy(self.s.store.value.get("bag_cleanup_pending"))
        if pending:
            require(all(pending.get(key) == value for key, value in params.items() if key != "kind")
                    and pending.get("task_identity") == task,
                    "이전 가방정리 판매의 같은 항목에서 마무리해 주세요.")
            require(type(pending.get("before_count")) is int and type(pending.get("quantity")) is int
                    and 0 < pending["quantity"] <= max(0, pending["before_count"]-keep)
                    and pending.get("phase") in ("sale_attempted", "close_pending"),
                    "이전 가방정리 판매 기록의 수량/단계를 확인해 주세요.")
        self.s.store.begin_run("bag_cleanup", signature, state="preparing", completed=0)
        if pending:
            self.s.prepare()
            self.port.close_pending()
            return self._finish(params, pending, recovered=True)
        stock = self._stock(params)
        quantity = stock["quantity"]
        if quantity <= 0:
            reason = "보존 수량 이하 · 정리 생략" if stock["before_count"] <= keep else "정리 가능한 초과 재료 없음 · 잠금 유지"
            self.s.store.set_run(state="complete", completed=0, requested_quantity=0, reason=reason)
            self.s.status(label + " · " + reason)
            return dict(state="complete", quantity=0, reason=reason)
        policy = item_rule(name)
        if policy["action"] == "unavailable":
            reason = "게임에서 판매/버리기를 제공하지 않는 재료 · 입력 없이 생략"
            self.s.store.set_run(state="complete", completed=0, requested_quantity=0, reason=reason)
            self.s.status(label + " · " + reason)
            return dict(state="complete", quantity=0, reason=reason)
        self.port.policy = policy
        action_label = "판매" if policy["action"] == "sell" else "버리기"
        self.s.prepare()
        self.s.status(label + f" · 인벤토리 {stock['before_count']:,}개 중 {quantity:,}개 {action_label} 입력 예정 · 보관함 제외")
        while True:
            stamp = getattr(self.port, "input_stamp", None)
            if callable(stamp) and stamp() != self._stock_input_stamp:
                stock = self._stock(params)
                quantity = stock["quantity"]
                if quantity <= 0:
                    self.s.store.set_run(state="complete", completed=0, requested_quantity=0,
                                        reason="사용자 입력 이후 초과 수량 없음 · 정리 생략")
                    return dict(state="complete", quantity=0)
            begin = getattr(self.port, "begin_context", None)
            if callable(begin):begin()
            # begin_context can observe a newer human-input stamp than the
            # pre-check above. Do not bind fresh input ownership to old stock.
            # Later activity is caught by the bound context at actual dispatch.
            if callable(stamp) and stamp() != self._stock_input_stamp:
                continue
            receipt = dict(params, before_count=stock["before_count"], quantity=quantity,
                           task_identity=task, phase="sale_attempted", action=policy["action"])
            def before_sale():
                self.s.check()
                before_stamp = stamp() if callable(stamp) else None
                self.s.store.set_bag_cleanup_pending(receipt)
                try:
                    validate = getattr(self.port, "validate_receipt", None)
                    if callable(validate):validate(before_stamp)
                    else:self.s.check()
                except (Cancelled, GameClientClosed, InputInterrupted):
                    # Still inside before_send: native mouse-down has not
                    # occurred. Clear only this provably unsent receipt.
                    self.s.store.set_bag_cleanup_pending(None)
                    raise
            def after_sale():
                receipt["phase"] = "close_pending"
                self.s.store.set_bag_cleanup_pending(receipt)
            try:
                self.port.open_search(name)
                item = self.port.observe_item(name)
                self.port.sell(item, quantity, before_sale, after_sale)
                break
            except InputInterrupted:
                self.s.check()
                self.s.status("가방정리 입력 문맥 변경 · 현재 화면/재고 재확인 · 이전 판매 재전송 없음")
                self.port.close_pending()
                if self.s.store.value.get("bag_cleanup_pending"):
                    return self._finish(params, receipt, recovered=True)
                stock = self._stock(params)
                quantity = stock["quantity"]
                if quantity <= 0:
                    self.s.store.set_run(state="complete", completed=0, requested_quantity=0,
                                        reason="사용자 입력 이후 초과 수량 없음 · 정리 생략")
                    return dict(state="complete", quantity=0)
        return self._finish(params, receipt)

    def _finish(self, params, receipt, *, recovered=False):
        # Completion describes the authored input path, not verified item loss.
        # A cancelled uncertain dispatch still resumes only its close tail; it
        # must not resend or wait for an inventory-delta proof after that tail.
        self.s.check()
        input_completed = receipt["phase"] == "close_pending"
        reason = ("이전 처리 마무리 · 재전송 없음" if recovered else "가방정리 입력 경로 완료")
        reason += " · 실제 처리 수량 미확인"
        self.s.store.set_run(state="complete", completed=1, requested_quantity=receipt["quantity"],
            input_completed=input_completed, reason=reason)
        self.s.store.set_bag_cleanup_pending(None)
        self.s.status(f"{params['name']} · {reason} · 요청 {receipt['quantity']:,}개")
        return dict(state="complete", requested_quantity=receipt["quantity"], input_completed=input_completed)
