"""People file (participant registry): read, compute, merge, write.

The people file (``people.yaml``) lives in the output directory and tracks
every participant across pipeline runs.  Computed stats are refreshed on
each run; human-editable fields (name, role, persona, notes) are preserved.
"""

from __future__ import annotations

import logging
import re
from datetime import datetime, timezone
from pathlib import Path
from typing import TYPE_CHECKING

import yaml

from bristlenose.models import (
    FullTranscript,
    InputSession,
    PeopleFile,
    PersonComputed,
    PersonEditable,
    PersonEntry,
    SpeakerRole,
)

if TYPE_CHECKING:
    from bristlenose.stages.identify_speakers import SpeakerInfo

logger = logging.getLogger(__name__)

PEOPLE_FILENAME = "people.yaml"

# ---------------------------------------------------------------------------
# Name-handling data (used by suggest_short_names)
# ---------------------------------------------------------------------------
#
# Honorific prefixes and family-first surnames are loaded from text files in
# bristlenose/data/.  See those files for sources, provenance, and how to
# expand them.

_DATA_DIR = Path(__file__).resolve().parent / "data"


def _load_data_file(filename: str) -> frozenset[str]:
    """Load a newline-delimited data file from ``bristlenose/data/``.

    Blank lines and lines starting with ``#`` are ignored.
    """
    path = _DATA_DIR / filename
    entries: set[str] = set()
    for line in path.read_text(encoding="utf-8").splitlines():
        stripped = line.strip()
        if stripped and not stripped.startswith("#"):
            entries.add(stripped)
    return frozenset(entries)


_HONORIFIC_PREFIXES: frozenset[str] = _load_data_file("honorifics.txt")
_FAMILY_FIRST_SURNAMES: frozenset[str] = _load_data_file("family_first_surnames.txt")

# Regex matching CJK Unified Ideographs, CJK Extension A, and Hangul
# Syllables.  Names containing these characters are already short enough to
# use as-is — we skip the "first token" heuristic entirely.
_CJK_RE = re.compile(r"[\u4e00-\u9fff\u3400-\u4dbf\uac00-\ud7af]")


def _strip_honorific(name: str) -> str:
    """Strip a leading honorific prefix from *name*.

    Returns the remainder of the name with the honorific removed, or the
    original name if no honorific is found.  Handles trailing periods on
    the prefix token (e.g. "Dr." → "Dr" → lookup).

    >>> _strip_honorific("Dr. Sarah Jones")
    'Sarah Jones'
    >>> _strip_honorific("Sarah Jones")
    'Sarah Jones'
    """
    parts = name.split(None, 1)
    if len(parts) < 2:
        return name
    token = parts[0].rstrip(".").lower()
    if token in _HONORIFIC_PREFIXES:
        return parts[1]
    return name

_HEADER = (
    "# people.yaml — participant registry (auto-generated by bristlenose)\n"
    "#\n"
    "# Computed fields are refreshed on each pipeline run.\n"
    "# Human-editable fields (full_name, short_name, role, persona, notes)\n"
    "# are preserved across runs — edit them freely.\n"
    "#\n"
    "# Set short_name to change how a participant appears in reports:\n"
    '#   short_name: Sarah  →  quotes render as "— Sarah" instead of "— p1"\n'
    "#\n"
    "# Note: inline YAML comments are lost when the file is rewritten.\n"
    "\n"
)


# ---------------------------------------------------------------------------
# Load
# ---------------------------------------------------------------------------


def load_people_file(output_dir: Path) -> PeopleFile | None:
    """Load an existing ``people.yaml`` from *output_dir*.

    Returns ``None`` if the file does not exist or is empty.
    """
    path = output_dir / PEOPLE_FILENAME
    if not path.exists():
        return None
    raw = yaml.safe_load(path.read_text(encoding="utf-8"))
    if raw is None:
        return None
    return PeopleFile.model_validate(raw)


# ---------------------------------------------------------------------------
# Compute
# ---------------------------------------------------------------------------


def compute_participant_stats(
    sessions: list[InputSession],
    transcripts: list[FullTranscript],
) -> dict[str, PersonComputed]:
    """Compute per-participant stats from session and transcript data.

    Returns a dict keyed by ``participant_id``.
    """
    transcript_map: dict[str, FullTranscript] = {
        t.session_id: t for t in transcripts
    }

    stats: dict[str, PersonComputed] = {}
    total_words_all = 0

    for session in sessions:
        sid = session.session_id
        transcript = transcript_map.get(sid)

        if transcript is None:
            # No transcript — create a placeholder for the primary participant
            pid = session.participant_id
            stats[pid] = PersonComputed(
                participant_id=pid,
                session_id=sid,
                session_date=session.session_date,
                duration_seconds=0.0,
                words_spoken=0,
                pct_words=0.0,
                pct_time_speaking=0.0,
                source_file=session.files[0].path.name if session.files else "",
            )
            continue

        # Group ALL segments by speaker_code, creating per-code entries
        code_words: dict[str, int] = {}
        code_seconds: dict[str, float] = {}
        for seg in transcript.segments:
            code = seg.speaker_code or session.participant_id
            if code not in code_words:
                code_words[code] = 0
                code_seconds[code] = 0.0
            code_words[code] += len(seg.text.split())
            code_seconds[code] += max(0.0, seg.end_time - seg.start_time)

        for code, words in code_words.items():
            if code.startswith("p"):
                total_words_all += words
            stats[code] = PersonComputed(
                participant_id=code,
                session_id=sid,
                session_date=transcript.session_date,
                duration_seconds=transcript.duration_seconds,
                words_spoken=words,
                pct_words=0.0,  # filled in second pass
                pct_time_speaking=(
                    round(code_seconds[code] / transcript.duration_seconds * 100, 1)
                    if transcript.duration_seconds > 0
                    else 0.0
                ),
                source_file=transcript.source_file,
            )

    # Second pass: compute pct_words relative to total across all participants.
    # Only count participant codes (pN) for the denominator.
    if total_words_all > 0:
        for computed in stats.values():
            if computed.participant_id.startswith("p"):
                computed.pct_words = round(
                    computed.words_spoken / total_words_all * 100, 1
                )

    return stats


# ---------------------------------------------------------------------------
# Merge
# ---------------------------------------------------------------------------


def merge_people(
    existing: PeopleFile | None,
    computed: dict[str, PersonComputed],
) -> PeopleFile:
    """Merge new computed stats with existing human-edited fields.

    Strategy:

    * **Computed fields** are always overwritten with fresh data.
    * **Editable fields** are always preserved from the previous file.
    * New participants are added with empty editable defaults.
    * Participants present in the old file but absent from the current run
      are **kept** (the user may still want their notes).
    """
    people = PeopleFile(last_updated=datetime.now(tz=timezone.utc))

    # Start with existing entries to preserve removed-but-noted participants.
    if existing:
        for pid, entry in existing.participants.items():
            people.participants[pid] = entry

    # Update / add computed stats.
    for pid, comp in computed.items():
        if pid in people.participants:
            # Preserve editable, replace computed.
            people.participants[pid].computed = comp
        else:
            people.participants[pid] = PersonEntry(
                computed=comp, editable=PersonEditable()
            )

    return people


# ---------------------------------------------------------------------------
# Write
# ---------------------------------------------------------------------------


def write_people_file(people: PeopleFile, output_dir: Path) -> Path:
    """Write ``people.yaml`` to *output_dir*.

    Uses block-style YAML with a human-readable comment header.
    """
    path = output_dir / PEOPLE_FILENAME
    output_dir.mkdir(parents=True, exist_ok=True)

    data = people.model_dump(mode="json")

    yaml_content = yaml.dump(
        data,
        default_flow_style=False,
        sort_keys=False,
        allow_unicode=True,
    )

    path.write_text(_HEADER + yaml_content, encoding="utf-8")
    logger.info("Wrote people file: %s", path)
    return path


# ---------------------------------------------------------------------------
# Display name helper
# ---------------------------------------------------------------------------


def build_display_name_map(people: PeopleFile) -> dict[str, str]:
    """Build a mapping from ``participant_id`` → display name.

    Returns ``short_name`` if set, otherwise ``participant_id``.
    """
    names: dict[str, str] = {}
    for pid, entry in people.participants.items():
        if entry.editable.short_name:
            names[pid] = entry.editable.short_name
        else:
            names[pid] = pid
    return names


# ---------------------------------------------------------------------------
# Name extraction from metadata
# ---------------------------------------------------------------------------

# Labels that are generic placeholders, not real names.
_GENERIC_LABEL_RE = re.compile(
    r"^(speaker\s*[a-z0-9]|SPEAKER_\d+|unknown|narrator)$",
    re.IGNORECASE,
)


def extract_names_from_labels(
    transcripts: list[FullTranscript],
) -> dict[str, str]:
    """Extract probable real names from ``speaker_label`` metadata.

    Teams/VTT transcripts often have real names as speaker labels
    (e.g. "Sarah Jones" instead of "Speaker A").  Returns
    ``{participant_id: name}`` for labels that look like real names.
    """
    names: dict[str, str] = {}
    for transcript in transcripts:
        pid = transcript.participant_id
        # Find the dominant PARTICIPANT-role speaker label.
        label_counts: dict[str, int] = {}
        for seg in transcript.segments:
            if seg.speaker_role == SpeakerRole.PARTICIPANT and seg.speaker_label:
                label_counts[seg.speaker_label] = (
                    label_counts.get(seg.speaker_label, 0) + 1
                )
        if not label_counts:
            continue
        # Pick the most frequent label.
        label = max(label_counts, key=label_counts.get)  # type: ignore[arg-type]
        # Skip generic labels.
        if _GENERIC_LABEL_RE.match(label):
            continue
        # Skip purely numeric labels.
        if label.strip().isdigit():
            continue
        names[pid] = label.strip()
    return names


# ---------------------------------------------------------------------------
# Auto-populate editable fields from extracted data
# ---------------------------------------------------------------------------


def auto_populate_names(
    people: PeopleFile,
    speaker_infos: dict[str, SpeakerInfo],
    label_names: dict[str, str],
) -> None:
    """Pre-populate empty ``full_name``/``role`` fields from extracted data.

    Only fills fields that are currently empty — never overwrites user edits.
    Priority: LLM-extracted name > speaker-label metadata name.

    Mutates *people* in place.
    """
    for pid, entry in people.participants.items():
        ed = entry.editable

        # full_name: LLM > label metadata
        if not ed.full_name:
            info = speaker_infos.get(pid)
            if info and info.person_name:
                ed.full_name = info.person_name
            elif pid in label_names:
                ed.full_name = label_names[pid]

        # role (job title): LLM only
        if not ed.role:
            info = speaker_infos.get(pid)
            if info and info.job_title:
                ed.role = info.job_title


# ---------------------------------------------------------------------------
# Short name suggestion
# ---------------------------------------------------------------------------


def suggest_short_names(people: PeopleFile) -> None:
    """Auto-suggest ``short_name`` for participants missing one.

    Uses the first token of ``full_name``.  When two participants share a
    first name, disambiguates with the last-name initial
    (e.g. "Sarah J." vs "Sarah K.").

    Only sets ``short_name`` on entries where it is currently empty.
    Mutates *people* in place.
    """
    # Collect candidates: entries that have full_name but no short_name.
    candidates: dict[str, str] = {}  # pid -> first_name
    for pid, entry in people.participants.items():
        if entry.editable.full_name and not entry.editable.short_name:
            first = entry.editable.full_name.split()[0]
            candidates[pid] = first

    if not candidates:
        return

    # Detect first-name collisions.
    first_counts: dict[str, int] = {}
    for first in candidates.values():
        first_counts[first] = first_counts.get(first, 0) + 1

    # Assign short names.
    for pid, first in candidates.items():
        if first_counts[first] > 1:
            # Disambiguate: append last-name initial if available.
            parts = people.participants[pid].editable.full_name.split()
            if len(parts) >= 2:
                short = f"{first} {parts[-1][0]}."
            else:
                short = first
        else:
            short = first
        people.participants[pid].editable.short_name = short
