"""Deterministically extract permitted metrics from immutable check-in Markdown."""

from __future__ import annotations

import hashlib
import json
import re
from collections import defaultdict
from dataclasses import dataclass
from pathlib import Path
from typing import Final

from checkin_cli.history_models import (
    Baseline,
    BaselineEntry,
    CanonicalDay,
    Coverage,
    DuplicateDay,
    Reconciliation,
    SourceRecord,
)


HEADING: Final[re.Pattern[str]] = re.compile(r"^##\s+D\+(\d+)\s+—\s+(\d{4}-\d{2}-\d{2})(?:\s|$)", re.MULTILINE)
WEIGHT: Final[re.Pattern[str]] = re.compile(r"^\s*-\s+(?:\*\*)?아침\s*공복\s*체중\s*[:：]?(?:\*\*)?\s*(\d+(?:\.\d+)?)\s*kg\b", re.MULTILINE)
SLEEP: Final[re.Pattern[str]] = re.compile(r"^\s*-\s+(?:\*\*)?수면\s*[:：]?(?:\*\*)?\s*(\d+(?:\.\d+)?)\s*시간\b", re.MULTILINE)
CALORIES: Final[re.Pattern[str]] = re.compile(r"^\s*-\s+(?:\*\*)?전날\s*총섭취\s*칼로리\s*[:：]?(?:\*\*)?\s*([\d,]+)\s*kcal\b", re.MULTILINE | re.IGNORECASE)
DIGESTION: Final[re.Pattern[str]] = re.compile(r"^\s*-\s+(?:\*\*)?소화\s*상태\s*[:：]?(?:\*\*)?\s*([012])\b", re.MULTILINE)
TRAINING: Final[re.Pattern[str]] = re.compile(r"^\s*-\s+(?:\*\*)?오늘\s*운동\s*[:：]?(?:\*\*)?\s*(.+)$", re.MULTILINE)
TABLE_ROW: Final[re.Pattern[str]] = re.compile(r"^\|\s*(\d{1,3})\s*\|\s*(\d{4}-\d{2}-\d{2})\s*\|\s*([^\n|]+)\|\s*([^\n|]+)\|\s*([^\n|]+)\|\s*([^\n|]+)\|\s*([^\n|]+)\|", re.MULTILINE)


@dataclass(frozen=True, slots=True)
class SourceInventory:
    """Verified immutable sources permitted to enter the historical importer."""

    sources: tuple[SourceRecord, ...]

    @classmethod
    def from_manifest(cls, manifest_path: Path) -> SourceInventory:
        """Load and verify the three explicit attachment paths and hashes."""
        raw = json.loads(manifest_path.read_text(encoding="utf-8"))
        records = tuple(SourceRecord(str(item["source_path"]), str(item["sha256"]), str(item["day_range"])) for item in raw["sources"])
        for record in records:
            actual = hashlib.sha256(Path(record.source_path).read_bytes()).hexdigest()
            if actual != record.sha256:
                raise RuntimeError(f"immutable historical source hash mismatch: {record.source_path}")
        return cls(records)


class HistoryBaselineParser:
    """Parse only explicitly labelled numeric fields and normalized status categories."""

    def __init__(self, inventory: SourceInventory) -> None:
        self._inventory = inventory

    def parse(self) -> Baseline:
        """Derive canonical daily entries, coverage, and duplicate reconciliation."""
        candidates = tuple(candidate for source in self._inventory.sources for candidate in self._source_candidates(source))
        grouped: dict[CanonicalDay, list[BaselineEntry]] = defaultdict(list)
        for candidate in candidates:
            grouped[candidate.day].append(candidate)
        entries = tuple(self._select(grouped[day]) for day in sorted(grouped))
        coverage = self._coverage(entries)
        reconciliation = Reconciliation(tuple(self._duplicates(day, items) for day, items in sorted(grouped.items()) if len(items) > 1))
        return Baseline(entries, coverage, reconciliation)

    def _source_candidates(self, source: SourceRecord) -> tuple[BaselineEntry, ...]:
        content = Path(source.source_path).read_text(encoding="utf-8")
        headings = tuple(HEADING.finditer(content))
        entries = [self._section_entry(source, match, content[match.end() : headings[index + 1].start() if index + 1 < len(headings) else len(content)]) for index, match in enumerate(headings)]
        entries.extend(self._table_entries(source, content))
        return tuple(entries)

    def _section_entry(self, source: SourceRecord, match: re.Match[str], section: str) -> BaselineEntry:
        day, recorded_date = CanonicalDay(int(match.group(1))), match.group(2)
        return BaselineEntry(day, recorded_date, source.source_path, source.sha256, f"D+{day}@{recorded_date}", self._number(WEIGHT, section), self._number(SLEEP, section), self._integer(CALORIES, section), self._training(section), self._digestion(section), self._pain(section))

    def _table_entries(self, source: SourceRecord, content: str) -> tuple[BaselineEntry, ...]:
        entries: list[BaselineEntry] = []
        for match in TABLE_ROW.finditer(content):
            day, recorded_date = CanonicalDay(int(match.group(1))), match.group(2)
            weight = self._plain_number(match.group(3))
            sleep = self._plain_number(match.group(4).split("/")[0])
            calories = self._plain_integer(match.group(6))
            entries.append(BaselineEntry(day, recorded_date, source.source_path, source.sha256, f"D+{day}@{recorded_date}:table", weight, sleep, calories, self._training_text(match.group(5)), self._digestion_text(match.group(7)), ()))
        return tuple(entries)

    @staticmethod
    def _number(pattern: re.Pattern[str], section: str) -> float | None:
        match = pattern.search(section)
        return float(match.group(1).replace(",", "")) if match else None

    @staticmethod
    def _integer(pattern: re.Pattern[str], section: str) -> int | None:
        match = pattern.search(section)
        return int(match.group(1).replace(",", "")) if match else None

    @staticmethod
    def _plain_number(value: str) -> float | None:
        match = re.fullmatch(r"\s*(\d+(?:\.\d+)?)\s*", value)
        return float(match.group(1)) if match else None

    @staticmethod
    def _plain_integer(value: str) -> int | None:
        match = re.fullmatch(r"\s*([\d,]+)\s*", value)
        return int(match.group(1).replace(",", "")) if match else None

    @staticmethod
    def _training(section: str) -> tuple[str, ...]:
        match = TRAINING.search(section)
        return HistoryBaselineParser._training_text(match.group(1)) if match else ()

    @staticmethod
    def _training_text(value: str) -> tuple[str, ...]:
        normalized = value.lower()
        tokens = tuple(token for label, token in (("push", "push"), ("푸쉬", "push"), ("pull", "pull"), ("풀", "pull"), ("legs", "legs"), ("레그", "legs"), ("등+가슴", "back_chest"), ("back", "back_chest"), ("어깨", "shoulders_arms"), ("휴식", "rest"), ("복싱", "boxing")) if label in normalized)
        return tuple(dict.fromkeys(tokens))

    @staticmethod
    def _digestion(section: str) -> str | None:
        match = DIGESTION.search(section)
        return HistoryBaselineParser._digestion_text(match.group(1)) if match else None

    @staticmethod
    def _digestion_text(value: str) -> str | None:
        return {"0": "good", "1": "normal", "2": "bloating"}.get(value.strip())

    @staticmethod
    def _pain(section: str) -> tuple[str, ...]:
        normalized = section.lower()
        tokens = tuple(token for label, token in (("무릎", "knee"), ("전경골근", "anterior_tibial"), ("가슴 통증", "chest_pain")) if label in normalized)
        return tuple(dict.fromkeys(tokens))

    @staticmethod
    def _select(candidates: list[BaselineEntry]) -> BaselineEntry:
        selected = max(candidates, key=lambda entry: (sum(value is not None for value in (entry.weight_kg, entry.sleep_hours, entry.calories_kcal, entry.digestion_status)) + len(entry.training_tokens) + len(entry.pain_tokens), entry.section_anchor.endswith(":table")))
        same_source = tuple(item for item in candidates if item.source_path == selected.source_path)
        return BaselineEntry(selected.day, selected.recorded_date, selected.source_path, selected.source_sha256, selected.section_anchor, HistoryBaselineParser._unique_value(selected.weight_kg, tuple(item.weight_kg for item in same_source)), HistoryBaselineParser._unique_value(selected.sleep_hours, tuple(item.sleep_hours for item in same_source)), HistoryBaselineParser._unique_value(selected.calories_kcal, tuple(item.calories_kcal for item in same_source)), HistoryBaselineParser._unique_tokens(selected.training_tokens, tuple(item.training_tokens for item in same_source)), HistoryBaselineParser._unique_value(selected.digestion_status, tuple(item.digestion_status for item in same_source)), HistoryBaselineParser._unique_tokens(selected.pain_tokens, tuple(item.pain_tokens for item in same_source)))

    @staticmethod
    def _unique_value[T](selected: T | None, candidates: tuple[T | None, ...]) -> T | None:
        values = {value for value in candidates if value is not None}
        return selected if selected is not None or len(values) != 1 else next(iter(values))

    @staticmethod
    def _unique_tokens(selected: tuple[str, ...], candidates: tuple[tuple[str, ...], ...]) -> tuple[str, ...]:
        return selected if selected else tuple(sorted({token for candidate in candidates for token in candidate}))

    @staticmethod
    def _coverage(entries: tuple[BaselineEntry, ...]) -> Coverage:
        observed = {entry.day for entry in entries}
        first, last = min(observed), max(observed)
        missing_days = tuple(CanonicalDay(day) for day in range(1, last + 1) if CanonicalDay(day) not in observed)
        days_without_measurements = tuple(entry.day for entry in entries if all(value is None or value == () for value in (entry.weight_kg, entry.sleep_hours, entry.calories_kcal, entry.digestion_status, entry.training_tokens, entry.pain_tokens)))
        return Coverage(first, last, missing_days, days_without_measurements, tuple(entry.day for entry in entries if entry.weight_kg is not None), tuple(entry.day for entry in entries if entry.sleep_hours is not None))

    @staticmethod
    def _duplicates(day: CanonicalDay, candidates: list[BaselineEntry]) -> DuplicateDay:
        fields = ("recorded_date", "weight_kg", "sleep_hours", "calories_kcal", "training_tokens", "digestion_status", "pain_tokens")
        conflicts = tuple(field for field in fields if len({getattr(item, field) for item in candidates if getattr(item, field) not in (None, ())}) > 1)
        return DuplicateDay(day, tuple(item.section_anchor for item in candidates), conflicts)
