"""Scoped extraction and orientation agents."""

import json
from typing import Dict

from backend import config
from backend.agents.base import AgentResult, AgentScope, AgentUsage, failure
from backend.llm import call_json
from backend.pipeline.extractor import (
    _normalize_sheet,
    _text_layer_block,
    discipline_from_sheet_number,
)
from backend.pipeline.sheet_index import _index_input
from backend.prompts import (
    EXTRACTOR_SYSTEM_PROMPT,
    EXTRACTOR_USER_INSTRUCTION,
    JURISDICTION_SYSTEM_PROMPT,
    JURISDICTION_USER_INSTRUCTION,
    SHEET_INDEX_SYSTEM_PROMPT,
    SHEET_INDEX_USER_INSTRUCTION,
)


# Appended to the extractor instruction on the second-chance retry. Dense plan
# sheets blow the output budget on the full schema; compact mode trades
# per-object verbosity for actually finishing the page.
_COMPACT_RETRY_SUFFIX = """
IMPORTANT - COMPACT RETRY: the first pass did not complete. Keep the SAME JSON
schema, but extract at most 40 objects, prioritizing coordination-relevant
items (equipment, fixtures, devices, keynotes, dimensions, markers/callouts,
schedule rows). Keep descriptions/attributes short; skip review_uses entries
you are unsure about. Finish the JSON - a smaller complete answer beats a
larger truncated one."""


def _wrap_bare_list(parsed, page_number: int):
    """Models sometimes skip the {sheet, objects} wrapper and return a bare
    objects array (especially after truncation repair). Accept it - the sheet
    header falls back to title-block deduction downstream."""
    if isinstance(parsed, list):
        print(f"[Extract] Page {page_number}: wrapping bare objects array "
              f"({len(parsed)} items, no sheet header)")
        return {"sheet": {}, "objects": parsed}
    return parsed


class SheetExtractorAgent:
    name = "sheet_extractor"

    def __init__(self, usage: AgentUsage) -> None:
        self.usage = usage

    def _call(self, instruction: str, page: Dict):
        return call_json(
            system_prompt=EXTRACTOR_SYSTEM_PROMPT,
            user_text=instruction,
            images_b64=[page["base64"]],
            max_tokens=config.EXTRACT_MAX_TOKENS,
            model=config.AGENT_EXTRACT_MODEL,
            usage_tracker=self.usage,
            usage_stage="agent.extract",
            reasoning_effort=config.EXTRACT_REASONING_EFFORT or None,
            reasoning_max_tokens=config.EXTRACT_REASONING_MAX_TOKENS or None,
        )

    def _text_structuring_call(self, instruction_page: Dict, sheet_hint: str):
        """Rung 2: text-only structuring pass over the page's text layer
        (no image). Recovers text content the vision pass missed."""
        from backend.prompts import (TEXT_STRUCTURING_SYSTEM_PROMPT,
                                     TEXT_STRUCTURING_USER_INSTRUCTION)
        instruction = (TEXT_STRUCTURING_USER_INSTRUCTION
                       .replace("{sheet_hint}", str(sheet_hint or ""))
                       .replace("{text_layer}",
                                (instruction_page.get("text_layer") or "")
                                [:config.TEXT_LAYER_MAX_CHARS]))
        return call_json(
            system_prompt=TEXT_STRUCTURING_SYSTEM_PROMPT,
            user_text=instruction,
            images_b64=None,
            max_tokens=config.EXTRACT_MAX_TOKENS,
            model=config.AGENT_EXTRACT_MODEL,
            usage_tracker=self.usage,
            usage_stage="agent.extract_text",
            reasoning_effort=config.EXTRACT_REASONING_EFFORT or None,
            reasoning_max_tokens=config.EXTRACT_REASONING_MAX_TOKENS or None,
        )

    def run(self, scope: AgentScope) -> AgentResult:
        from backend.text_coverage import (fallback_objects, merge_objects,
                                           recover_sheet_number, text_coverage)
        try:
            page = scope.payload["page"]
            hint = scope.payload.get("sheet_hint") or ""
            page_text = page.get("text_layer")
            instruction = EXTRACTOR_USER_INSTRUCTION.replace(
                "{sheet_hint}", str(hint)) + _text_layer_block(page)

            # Rung 1: vision pass (unchanged behaviour, incl. compact retry)
            parsed = _wrap_bare_list(self._call(instruction, page),
                                     page["page_number"])
            if not isinstance(parsed, dict):
                # Second chance: same page, compact instructions. Runs only
                # when the full-schema pass returned nothing usable.
                print(f"[Extract] Page {page['page_number']}: full extraction "
                      f"failed, retrying compact")
                parsed = _wrap_bare_list(
                    self._call(instruction + _COMPACT_RETRY_SUFFIX, page),
                    page["page_number"],
                )
            if not isinstance(parsed, dict):
                # Don't give up on the page - the ladder below can still
                # rescue it from the text layer.
                parsed = {"sheet": {}, "objects": []}

            sheet = _normalize_sheet(parsed, page["page_number"],
                                     page_text=page_text)
            cov = text_coverage(page_text or "", sheet["assertions"])
            sheet["coverage"] = cov

            # Rung 2: text-only structuring when coverage is below floor.
            # MERGE, never replace: vision keeps every object it found
            # (graphical_basis content exists only in the image); the text
            # pass fills in the text content the vision pass missed.
            if (page_text and config.EXTRACT_TEXT_RETRY_ENABLED
                    and cov["ratio"] < config.EXTRACT_COVERAGE_FLOOR):
                print(f"[Extract] Page {page['page_number']}: coverage "
                      f"{cov['ratio']:.0%} < floor - text-only structuring pass")
                parsed2 = _wrap_bare_list(
                    self._text_structuring_call(page, hint), page["page_number"])
                if isinstance(parsed2, dict):
                    sheet2 = _normalize_sheet(parsed2, page["page_number"],
                                              page_text=page_text)
                    before = len(sheet["assertions"])
                    sheet["assertions"] = merge_objects(sheet["assertions"],
                                                        sheet2["assertions"])
                    # Fill header gaps the vision pass left null
                    for key in ("sheet_number", "sheet_title", "discipline",
                                "level", "scale", "drawing_type"):
                        if not sheet.get(key) and sheet2.get(key):
                            sheet[key] = sheet2[key]
                    cov = text_coverage(page_text, sheet["assertions"])
                    sheet["coverage"] = cov
                    print(f"[Extract] Page {page['page_number']}: merged "
                          f"{len(sheet['assertions']) - before} text-structured "
                          f"object(s), coverage now {cov['ratio']:.0%}")

            # Rung 3: deterministic fallback - dark sheets are impossible.
            # Also merged (deduped) so stub notes never double up with
            # objects the earlier rungs already captured.
            if (page_text and config.EXTRACT_FALLBACK_ENABLED
                    and cov["ratio"] < config.EXTRACT_COVERAGE_FLOOR):
                stubs = fallback_objects(page_text, page["page_number"],
                                         config.EXTRACT_FALLBACK_MAX_OBJECTS)
                stubs = _normalize_sheet({"sheet": {}, "objects": stubs},
                                         page["page_number"],
                                         page_text=page_text)["assertions"]
                # _normalize_sheet only stamps its own "text_layer" rescue
                # grounding; restore the explicit fallback provenance.
                for stub in stubs:
                    stub["grounding"] = "text_layer_fallback"
                before = len(sheet["assertions"])
                sheet["assertions"] = merge_objects(sheet["assertions"], stubs)
                print(f"[Extract] Page {page['page_number']}: fallback merged "
                      f"{len(sheet['assertions']) - before} text-layer stub(s)")
                sheet["coverage"] = text_coverage(page_text,
                                                  sheet["assertions"])

            # Identity recovery: never leave a text-bearing page sheet-less
            if not sheet.get("sheet_number") and page_text:
                recovered = recover_sheet_number(page_text)
                if recovered:
                    sheet["sheet_number"] = recovered
                    sheet["discipline"] = (
                        discipline_from_sheet_number(recovered)
                        or sheet.get("discipline") or "Unknown")
                    print(f"[Extract] Page {page['page_number']}: sheet number "
                          f"recovered from text layer -> {recovered}")

            return AgentResult(scope_id=scope.scope_id, artifacts=[sheet])
        except Exception as exc:
            return failure(scope, exc)


class SheetIndexAgent:
    name = "sheet_index"

    def __init__(self, usage: AgentUsage) -> None:
        self.usage = usage

    def run(self, scope: AgentScope) -> AgentResult:
        try:
            sheets = scope.payload.get("sheets") or []
            instruction = SHEET_INDEX_USER_INSTRUCTION.replace(
                "{sheet_index_input}",
                json.dumps(_index_input(sheets), ensure_ascii=True),
            )
            parsed = call_json(
                system_prompt=SHEET_INDEX_SYSTEM_PROMPT,
                user_text=instruction,
                max_tokens=config.SHEET_INDEX_MAX_TOKENS,
                model=config.AGENT_INDEX_MODEL,
                usage_tracker=self.usage,
                usage_stage="agent.sheet_index",
            )
            if isinstance(parsed, list):
                parsed = {"sheet_index": parsed, "missing_expected_sheets": []}
            if not isinstance(parsed, dict):
                raise ValueError("no sheet index returned")
            return AgentResult(scope_id=scope.scope_id, artifacts=[parsed])
        except Exception as exc:
            return failure(scope, exc)


class JurisdictionAgent:
    name = "jurisdiction"

    def __init__(self, usage: AgentUsage) -> None:
        self.usage = usage

    def run(self, scope: AgentScope) -> AgentResult:
        try:
            project_input: Dict = scope.payload.get("project_input") or {}
            instruction = JURISDICTION_USER_INSTRUCTION.replace(
                "{project_input}", json.dumps(project_input, ensure_ascii=True)
            )
            parsed = call_json(
                system_prompt=JURISDICTION_SYSTEM_PROMPT,
                user_text=instruction,
                max_tokens=config.JURISDICTION_MAX_TOKENS,
                model=config.AGENT_JURISDICTION_MODEL,
                usage_tracker=self.usage,
                usage_stage="agent.jurisdiction",
            )
            if not isinstance(parsed, dict):
                raise ValueError("no jurisdiction profile returned")
            profile = parsed.get("project_code_profile")
            artifact = profile if isinstance(profile, dict) else parsed
            return AgentResult(scope_id=scope.scope_id, artifacts=[artifact])
        except Exception as exc:
            return failure(scope, exc)
