"""PDF report logic extractor.

Extracts business logic from PDF reports and documentation:
- KPI / measure definitions from text patterns
- Calculation formulas and expressions
- Table headers as grain indicators
- Report section references as dependencies
- Filter conditions described in text
"""
from __future__ import annotations

import logging
import re
from pathlib import Path
from typing import Dict, List, Optional, Set, Tuple

from ..constants import (
    FilterOperator,
    MeasureAggregation,
    SourceType,
)
from ..contracts import (
    Dependency,
    FilterPredicate,
    GrainColumn,
    LogicArtifact,
    LogicObjects,
    Measure,
)

logger = logging.getLogger(__name__)

# ---------------------------------------------------------------------------
# Regex patterns for PDF text analysis
# ---------------------------------------------------------------------------

# KPI / measure definitions:
#   "Revenue = SUM(Sales Amount)"
#   "Net Income: Total Revenue - Total Expenses"
#   "Gross Margin = (Revenue - COGS) / Revenue"
_KPI_DEFINITION_PATTERN = re.compile(
    r"(?:^|\n)\s*([A-Z][A-Za-z0-9 _/%-]{2,50}?)\s*[:=]\s*"
    r"((?:SUM|COUNT|AVG|AVERAGE|MIN|MAX|TOTAL|DISTINCT\s+COUNT)"
    r"\s*\([^)]+\)|[^.\n]{5,120})",
    re.IGNORECASE | re.MULTILINE,
)

# Aggregation function in text
_TEXT_AGG_PATTERN = re.compile(
    r"\b(SUM|COUNT|AVERAGE|AVG|MIN|MAX|TOTAL|DISTINCT\s+COUNT|MEDIAN|STDEV)\b"
    r"\s*(?:\(|of\b)",
    re.IGNORECASE,
)

# Inline formula: "= SUM(...)" or "= A - B / C"
_FORMULA_PATTERN = re.compile(
    r"=\s*((?:SUM|COUNT|AVG|AVERAGE|MIN|MAX)\s*\([^)]+\)"
    r"|[A-Za-z0-9 _]+(?:\s*[-+*/]\s*[A-Za-z0-9 _()]+)+)",
    re.IGNORECASE,
)

# Percentage / ratio pattern: "Gross Margin %", "Win Rate (%)"
_PERCENT_METRIC_PATTERN = re.compile(
    r"([A-Z][A-Za-z0-9 _-]{2,40})\s*(?:%|\(?\s*%\s*\)?|percentage|ratio)",
    re.IGNORECASE,
)

# Table header row: consecutive capitalized words separated by | or tabs
_TABLE_HEADER_PATTERN = re.compile(
    r"(?:^|\n)\s*(?:[A-Z][A-Za-z0-9 _/]+\s*[|\t]\s*){2,}[A-Z][A-Za-z0-9 _/]+",
    re.MULTILINE,
)

# "Group by", "Grouped by", "broken down by", "segmented by", "per"
_GROUPBY_TEXT_PATTERN = re.compile(
    r"(?:group(?:ed)?\s+by|broken\s+down\s+by|segmented\s+by|aggregated\s+by"
    r"|summarized\s+by|by\s+(?:each\s+)?)"
    r"\s+([A-Za-z0-9 _,]+?)(?:[.,;:\n]|$)",
    re.IGNORECASE,
)

# "per Customer", "per Region" — separate pattern to avoid double-space issue
_PER_PATTERN = re.compile(
    r"\bper\s+([A-Za-z][A-Za-z0-9 _,]+?)(?:[.,;:\n]|$)",
    re.IGNORECASE,
)

# "filtered by", "where", "excluding", "only for"
_FILTER_TEXT_PATTERN = re.compile(
    r"(?:filter(?:ed)?\s+(?:by|on|for)|where\s+|excluding?\s+|only\s+(?:for|where|when)\s+)"
    r"([A-Za-z0-9 _=<>!'\",]+?)(?:[.;\n]|$)",
    re.IGNORECASE,
)

# Comparison pattern in text: "Amount > 1000", "Status = 'Active'"
_TEXT_COMPARISON_PATTERN = re.compile(
    r"([A-Za-z][A-Za-z0-9 _]+?)\s*(>=|<=|!=|<>|>|<|=)\s*(['\"]?[A-Za-z0-9 _.,-]+['\"]?)",
)

# Report/source references: "Source: SAP", "Data from: GL Table"
_SOURCE_REF_PATTERN = re.compile(
    r"(?:source|data\s+from|based\s+on|from\s+(?:the\s+)?|extracted\s+from|report)"
    r"\s*[:=]?\s*([A-Za-z][A-Za-z0-9 _.-]{2,60})",
    re.IGNORECASE,
)

# Section headers (numbered or bold-style)
_SECTION_HEADER_PATTERN = re.compile(
    r"(?:^|\n)\s*(?:\d+[.)]\s*|[A-Z]{2,}:?\s+)([A-Z][A-Za-z0-9 _-]{3,60})",
    re.MULTILINE,
)

# Map text aggregation names to our enum
_TEXT_AGG_MAP: Dict[str, MeasureAggregation] = {
    "SUM": MeasureAggregation.SUM,
    "TOTAL": MeasureAggregation.SUM,
    "COUNT": MeasureAggregation.COUNT,
    "DISTINCT COUNT": MeasureAggregation.COUNT_DISTINCT,
    "AVG": MeasureAggregation.AVG,
    "AVERAGE": MeasureAggregation.AVG,
    "MEDIAN": MeasureAggregation.CUSTOM,
    "STDEV": MeasureAggregation.CUSTOM,
    "MIN": MeasureAggregation.MIN,
    "MAX": MeasureAggregation.MAX,
}

# Filter operator map for text comparisons
_TEXT_OP_MAP: Dict[str, FilterOperator] = {
    "=": FilterOperator.EQ,
    "!=": FilterOperator.NEQ,
    "<>": FilterOperator.NEQ,
    ">": FilterOperator.GT,
    ">=": FilterOperator.GTE,
    "<": FilterOperator.LT,
    "<=": FilterOperator.LTE,
}


class PDFLogicExtractor:
    """Extract business logic from PDF reports and documentation.

    Handles:
    - KPI definitions (Name = Expression or Name: Expression)
    - Aggregation functions mentioned in text
    - Percentage/ratio metrics
    - Table headers as grain indicators
    - "Group by" / "segmented by" text as grain
    - Filter conditions from text
    - Source / report references as dependencies
    """

    def extract(
        self,
        source: str,
        source_path: str = "",
        source_name: str = "",
    ) -> LogicArtifact:
        """Parse extracted PDF text and identify business logic elements.

        Args:
            source: Plain text extracted from PDF (or raw text for testing).
            source_path: Original file path.
            source_name: Display name.
        """
        if not source or not source.strip():
            return LogicArtifact(
                source_type=SourceType.PDF,
                source_path=source_path,
                source_name=source_name,
                raw_source="",
                confidence=0.0,
                explanation="Empty PDF text",
            )

        try:
            measures = self._extract_measures(source)
            filters = self._extract_filters(source)
            grain_columns = self._extract_grain(source)
            dependencies = self._extract_dependencies(source)
            confidence = self._compute_confidence(measures, filters, grain_columns, dependencies)

            grain_desc = ""
            if grain_columns:
                grain_desc = ", ".join(g.column for g in grain_columns)

            return LogicArtifact(
                source_type=SourceType.PDF,
                source_path=source_path,
                source_name=source_name,
                raw_source=source[:2000],
                grain=grain_desc,
                confidence=confidence,
                objects=LogicObjects(
                    measures=measures,
                    filters=filters,
                    grain_columns=grain_columns,
                    dependencies=dependencies,
                ),
                explanation=self._build_explanation(measures, filters, grain_columns, dependencies),
            )
        except Exception as e:
            logger.warning("PDF parse error for %s: %s", source_path or source_name, e)
            return LogicArtifact(
                source_type=SourceType.PDF,
                source_path=source_path,
                source_name=source_name,
                raw_source=source[:2000],
                confidence=0.0,
                explanation=f"Parse error: {e}",
            )

    def extract_file(self, file_path: str) -> LogicArtifact:
        """Extract logic from a PDF file on disk.

        Uses PyPDF2 to extract text, then analyses the text content.
        Falls back to raw binary text extraction if PyPDF2 is unavailable.
        """
        path = Path(file_path)
        if not path.exists():
            return LogicArtifact(
                source_type=SourceType.PDF,
                source_path=file_path,
                confidence=0.0,
                explanation=f"File not found: {file_path}",
            )

        text = self._read_pdf(file_path)
        if not text.strip():
            return LogicArtifact(
                source_type=SourceType.PDF,
                source_path=file_path,
                source_name=path.stem,
                raw_source="",
                confidence=0.0,
                explanation="No extractable text in PDF",
            )

        return self.extract(text, source_path=file_path, source_name=path.stem)

    def _read_pdf(self, file_path: str) -> str:
        """Extract text from PDF using available libraries."""
        # Try PyPDF2 first
        try:
            from PyPDF2 import PdfReader

            reader = PdfReader(file_path)
            pages: List[str] = []
            for page in reader.pages:
                page_text = page.extract_text()
                if page_text:
                    pages.append(page_text)
            return "\n\n".join(pages)
        except ImportError:
            pass
        except Exception as e:
            logger.warning("PyPDF2 error for %s: %s", file_path, e)

        # Try pdfplumber as fallback
        try:
            import pdfplumber

            pages: List[str] = []
            with pdfplumber.open(file_path) as pdf:
                for page in pdf.pages:
                    page_text = page.extract_text()
                    if page_text:
                        pages.append(page_text)
            return "\n\n".join(pages)
        except ImportError:
            pass
        except Exception as e:
            logger.warning("pdfplumber error for %s: %s", file_path, e)

        logger.warning("No PDF library available for %s", file_path)
        return ""

    # ------------------------------------------------------------------
    # Measure extraction
    # ------------------------------------------------------------------

    def _extract_measures(self, source: str) -> List[Measure]:
        """Extract KPI definitions, formulas, and metric references from text."""
        measures: List[Measure] = []
        seen_names: Set[str] = set()

        # 1. Explicit KPI definitions: "Revenue = SUM(Sales Amount)"
        for match in _KPI_DEFINITION_PATTERN.finditer(source):
            name = match.group(1).strip()
            expr = match.group(2).strip()

            # Skip overly generic names
            if len(name) < 3 or name.lower() in seen_names:
                continue
            if name.lower() in ("the", "this", "that", "note", "page", "table", "figure"):
                continue

            agg = self._detect_aggregation(expr)
            measures.append(Measure(
                name=name,
                expression=expr[:500],
                aggregation=agg,
                confidence=0.70,
            ))
            seen_names.add(name.lower())

        # 2. Percentage/ratio metrics: "Gross Margin %"
        for match in _PERCENT_METRIC_PATTERN.finditer(source):
            name = match.group(1).strip()
            if len(name) < 3 or name.lower() in seen_names:
                continue
            if name.lower() in ("the", "this", "that", "note", "page"):
                continue

            measures.append(Measure(
                name=f"{name} %",
                expression=f"{name} (percentage/ratio)",
                aggregation=MeasureAggregation.CUSTOM,
                confidence=0.55,
            ))
            seen_names.add(name.lower())

        # 3. Inline formulas: "= SUM(Revenue) - SUM(Costs)"
        for match in _FORMULA_PATTERN.finditer(source):
            expr = match.group(1).strip()
            # Generate a name from the expression
            name = self._formula_to_name(expr)
            if name.lower() in seen_names or len(name) < 3:
                continue

            agg = self._detect_aggregation(expr)
            measures.append(Measure(
                name=name,
                expression=expr[:500],
                aggregation=agg,
                confidence=0.60,
            ))
            seen_names.add(name.lower())

        return measures

    def _detect_aggregation(self, expr: str) -> MeasureAggregation:
        """Detect aggregation type from expression text."""
        match = _TEXT_AGG_PATTERN.search(expr)
        if match:
            func = match.group(1).upper()
            return _TEXT_AGG_MAP.get(func, MeasureAggregation.CUSTOM)
        # Check for arithmetic operators (likely a calculated measure)
        if re.search(r"[-+*/]", expr):
            return MeasureAggregation.CUSTOM
        return MeasureAggregation.CUSTOM

    def _formula_to_name(self, expr: str) -> str:
        """Generate a descriptive name from a formula expression."""
        # Try to use the function name and first argument
        match = re.match(r"(SUM|COUNT|AVG|AVERAGE|MIN|MAX)\s*\(([^)]+)\)", expr, re.IGNORECASE)
        if match:
            func = match.group(1).upper()
            arg = match.group(2).strip()[:30]
            return f"{func}({arg})"
        # Truncate for calculated expressions
        return expr[:50].strip()

    # ------------------------------------------------------------------
    # Filter extraction
    # ------------------------------------------------------------------

    def _extract_filters(self, source: str) -> List[FilterPredicate]:
        """Extract filter conditions from descriptive text."""
        filters: List[FilterPredicate] = []
        seen: Set[str] = set()

        # 1. Explicit filter text: "filtered by Status = Active"
        for match in _FILTER_TEXT_PATTERN.finditer(source):
            clause = match.group(1).strip()
            key = clause.lower()
            if key in seen:
                continue

            # Try to parse comparison within the filter clause
            cmp = _TEXT_COMPARISON_PATTERN.search(clause)
            if cmp:
                col = cmp.group(1).strip()
                op = cmp.group(2).strip()
                val = cmp.group(3).strip().strip("'\"")
                filters.append(FilterPredicate(
                    column=col,
                    operator=_TEXT_OP_MAP.get(op, FilterOperator.EQ),
                    value=val,
                    source_clause=clause[:200],
                    confidence=0.55,
                ))
            else:
                filters.append(FilterPredicate(
                    column="",
                    operator=FilterOperator.EQ,
                    value=clause[:100],
                    source_clause=clause[:200],
                    confidence=0.40,
                ))
            seen.add(key)

        # 2. Standalone comparisons in text: "Amount > 1000"
        for match in _TEXT_COMPARISON_PATTERN.finditer(source):
            col = match.group(1).strip()
            op = match.group(2).strip()
            val = match.group(3).strip().strip("'\"")
            key = f"{col}|{op}|{val}".lower()
            if key in seen:
                continue

            # Skip very common non-filter patterns
            if col.lower() in ("version", "page", "figure", "table", "section", "step"):
                continue

            filters.append(FilterPredicate(
                column=col,
                operator=_TEXT_OP_MAP.get(op, FilterOperator.EQ),
                value=val,
                source_clause=match.group(0)[:200],
                confidence=0.45,
            ))
            seen.add(key)

        return filters

    # ------------------------------------------------------------------
    # Grain extraction
    # ------------------------------------------------------------------

    def _extract_grain(self, source: str) -> List[GrainColumn]:
        """Extract grain from 'group by' text and table headers."""
        grain_columns: List[GrainColumn] = []
        seen: Set[str] = set()

        # 1. "group by", "segmented by" patterns
        for match in _GROUPBY_TEXT_PATTERN.finditer(source):
            group_text = match.group(1).strip()
            # Split on commas and "and"
            parts = re.split(r"\s*,\s*|\s+and\s+", group_text)
            for part in parts:
                col = part.strip()
                if col and len(col) > 1 and col.lower() not in seen:
                    grain_columns.append(GrainColumn(
                        column=col,
                    ))
                    seen.add(col.lower())

        # 1b. "per X" pattern (separate to avoid double-space issue)
        for match in _PER_PATTERN.finditer(source):
            group_text = match.group(1).strip().rstrip(".")
            parts = re.split(r"\s*,\s*|\s+and\s+", group_text)
            for part in parts:
                col = part.strip()
                if col and len(col) > 1 and col.lower() not in seen:
                    grain_columns.append(GrainColumn(
                        column=col,
                    ))
                    seen.add(col.lower())

        # 2. Table headers (pipe-separated or tab-separated)
        for match in _TABLE_HEADER_PATTERN.finditer(source):
            header_row = match.group(0).strip()
            if "|" in header_row:
                cols = [c.strip() for c in header_row.split("|") if c.strip()]
            else:
                cols = [c.strip() for c in header_row.split("\t") if c.strip()]

            for col in cols:
                if col and len(col) > 1 and col.lower() not in seen:
                    # Only include if it looks like a dimension column (not numeric)
                    if not re.match(r"^\d+[.,\d]*$", col):
                        grain_columns.append(GrainColumn(
                            column=col,
                        ))
                        seen.add(col.lower())

        return grain_columns

    # ------------------------------------------------------------------
    # Dependency extraction
    # ------------------------------------------------------------------

    def _extract_dependencies(self, source: str) -> List[Dependency]:
        """Extract source/report references as dependencies."""
        deps: List[Dependency] = []
        seen: Set[str] = set()

        # 1. Source references: "Source: SAP GL Table"
        for match in _SOURCE_REF_PATTERN.finditer(source):
            ref = match.group(1).strip()
            # Clean trailing punctuation
            ref = ref.rstrip(".,;:)")
            if ref and len(ref) > 2 and ref.lower() not in seen:
                # Skip generic words
                if ref.lower() not in ("the", "this", "data", "report", "system", "table"):
                    deps.append(Dependency(
                        name=ref,
                        dep_type="source_system",
                    ))
                    seen.add(ref.lower())

        # 2. Section headers as report structure dependencies
        for match in _SECTION_HEADER_PATTERN.finditer(source):
            section = match.group(1).strip()
            if section and len(section) > 3 and section.lower() not in seen:
                deps.append(Dependency(
                    name=section,
                    dep_type="report_section",
                ))
                seen.add(section.lower())

        return deps

    # ------------------------------------------------------------------
    # Confidence
    # ------------------------------------------------------------------

    def _compute_confidence(
        self,
        measures: List[Measure],
        filters: List[FilterPredicate],
        grain: List[GrainColumn],
        deps: List[Dependency],
    ) -> float:
        """Compute overall confidence score.

        PDF extraction is inherently lower confidence than structured
        formats (SQL, DAX) because we're parsing natural language text.
        """
        score = 0.0
        if measures:
            score += 0.25
            # Bonus for explicit formula definitions
            explicit = sum(1 for m in measures if m.confidence >= 0.65)
            if explicit > 0:
                score += 0.10
        if filters:
            score += 0.15
        if grain:
            score += 0.15
        if deps:
            score += 0.10
            # Bonus for source system refs (more useful than section headers)
            source_refs = sum(1 for d in deps if d.dep_type == "source_system")
            if source_refs > 0:
                score += 0.05
        return min(round(score, 2), 1.0)

    def _build_explanation(
        self,
        measures: List[Measure],
        filters: List[FilterPredicate],
        grain: List[GrainColumn],
        deps: List[Dependency],
    ) -> str:
        parts = []
        if measures:
            names = [m.name for m in measures[:5]]
            parts.append(f"{len(measures)} measure(s): {', '.join(names)}")
        if filters:
            parts.append(f"{len(filters)} filter(s)")
        if grain:
            cols = [g.column for g in grain[:5]]
            parts.append(f"grain: {', '.join(cols)}")
        if deps:
            sources = [d.name for d in deps if d.dep_type == "source_system"]
            sections = [d.name for d in deps if d.dep_type == "report_section"]
            if sources:
                parts.append(f"source(s): {', '.join(sources[:3])}")
            if sections:
                parts.append(f"{len(sections)} section(s)")
        return "; ".join(parts) if parts else "No business logic elements detected"
