"""Clockify CSV parsing — the faithful Python port of the legacy
``lib/clockify.ts`` (imports.md §C–F; 06-test-plan §2.1 T-CLK-*).

Clockify's default Detailed Report layout:
  Project, Client, Description, Task, User, Group, Email, Tags,
  Billable, Start Date, Start Time, End Date, End Time,
  Duration (h), Duration (decimal), Billable Rate (USD),
  Billable Amount (USD)

Dates and times are LOCAL, without TZ — kept naive end-to-end (07 #17).

Port fidelity notes (each deliberate; ADR #005):
  - Toggl header aliases (member / stop date / stop time) do NOT exist
    (judgment #8: the importer speaks Clockify + manual only —
    T-IMP-009). The `task` column (indexed-but-never-read in the
    legacy parser) and the `time_format` plumbing (accepted-then-
    ignored) are dropped as dead code; ``parse_time_only`` keeps its
    ignored ``fmt`` argument so the CLK-026/027 fixtures port verbatim.
  - Invalid END date/time now surfaces as a WARNING on the result
    (T-IMP-011 rebuild half — the legacy app silently nulled it);
    `errors` keeps the exact legacy semantics (invalid START rows).
  - JS ``Math.round`` (half-away-from-zero for the non-negative inputs
    here) is reproduced with ``floor(x + 0.5)`` — Python's ``round()``
    is banker's and would drift on exact halves.
"""

from __future__ import annotations

import csv as csv_module
import io
import math
import re
from dataclasses import asdict, dataclass, field

DATE_FORMATS = ("YYYY-MM-DD", "MM/DD/YYYY", "DD/MM/YYYY")

#: The parser fallback when detection abstains. The legacy app fell back
#: to a form selector; the rebuild has no format pickers (imports.md UI
#: notes: "auto-detection over configuration"), so the Clockify US
#: default is the fixed fallback.
DEFAULT_DATE_FORMAT = "MM/DD/YYYY"


@dataclass
class ParsedEntry:
    """One normalized CSV row — persisted verbatim as jsonb in
    pending_imports.parsed_entries (imports.md B28, raw_*/source_rate
    column names per 02 §3)."""

    source_user_name: str
    source_user_email: str
    operator: str  # raw Clockify "Client" column = the operator agency
    client: str | None  # parsed end-client (left of " : ")
    project: str  # remainder of the CSV "Project" field
    description: str
    billable: bool
    start_at: str  # 'YYYY-MM-DD HH:MM:SS' (naive)
    end_at: str | None
    duration_seconds: int | None
    source_rate: float | None
    source_amount: float | None

    def as_dict(self) -> dict:
        return asdict(self)


@dataclass
class ParseResult:
    entries: list[ParsedEntry] = field(default_factory=list)
    errors: list[dict] = field(default_factory=list)  # [{row, message}]
    warnings: list[dict] = field(default_factory=list)  # rebuild: invalid END etc.
    total_rows: int = 0
    unique_emails: list[str] = field(default_factory=list)
    date_range: dict | None = None  # {"min": ..., "max": ...} of START dates
    date_format_used: str | None = None  # detected ?? fallback (provenance)


def split_client_project(raw: str) -> tuple[str | None, str]:
    """Split the legacy ``{Client} : {Project}`` convention on the FIRST
    occurrence of the literal ``" : "`` (space-colon-space — strict; no
    separator means client is None and the whole string is the project).
    """
    sep = " : "
    idx = raw.find(sep)
    if idx == -1:
        return None, raw.strip()
    client = raw[:idx].strip()
    return (client or None), raw[idx + len(sep) :].strip()


# Clockify-only header candidates (judgment #8 — no Toggl aliases).
# First matching column index wins; matching is exact on the
# lowercased+trimmed header cell.
HEADERS: dict[str, tuple[str, ...]] = {
    "project": ("project",),
    "client": ("client",),
    "description": ("description",),
    "user": ("user",),
    "email": ("email",),
    "billable": ("billable",),
    "start_date": ("start date", "startdate"),
    "start_time": ("start time", "starttime"),
    "end_date": ("end date", "enddate"),
    "end_time": ("end time", "endtime"),
    "duration": ("duration (h)", "duration"),
    "rate": ("billable rate (usd)", "billable rate"),
    "amount": ("billable amount (usd)", "billable amount"),
}

REQUIRED_KEYS = (
    "project",
    "client",
    "description",
    "email",
    "start_date",
    "start_time",
    "end_date",
    "end_time",
    "duration",
)

_ISO_RE = re.compile(r"^\d{4}-\d{1,2}-\d{1,2}$")
_ISO_CAPTURE_RE = re.compile(r"^(\d{4})-(\d{1,2})-(\d{1,2})$")
_SLASH_RE = re.compile(r"^(\d{1,4})[/\-.](\d{1,4})[/\-.](\d{1,4})$")
_MERIDIEM_SUFFIX_RE = re.compile(r"\s*[AaPp][Mm]\s*$")
_TIME_12H_RE = re.compile(r"^(\d{1,2}):(\d{2})(?::(\d{2}))?\s*([AaPp][Mm])$")
_TIME_24H_RE = re.compile(r"^(\d{1,2}):(\d{2})(?::(\d{2}))?$")
_HMS_RE = re.compile(r"^(\d+):(\d{2}):(\d{2})$")
_HM_RE = re.compile(r"^(\d+):(\d{2})$")
_DATE_LABEL_RE = re.compile(r"^(\d{4})-(\d{2})-(\d{2})$")


def _js_round(x: float) -> int:
    """JS Math.round for the non-negative values used here."""
    return math.floor(x + 0.5)


def _build_header_index(header: list[str]) -> dict[str, int]:
    normalized = [cell.lower().strip() for cell in header]
    index: dict[str, int] = {}
    for key, candidates in HEADERS.items():
        found = -1
        for i, cell in enumerate(normalized):
            if cell in candidates:
                found = i
                break
        index[key] = found
    return index


def detect_date_format(dates: list[str]) -> str | None:
    """Sniff the date format used across a list of date strings
    (imports.md B17–B19):

      - any leading 4-digit year -> ISO signal;
      - any first slot > 12 -> DD/MM signal; else any second slot > 12
        -> MM/DD signal;
      - ISO wins when it is the only signal, and also when it coexists
        with BOTH slash signals (mixed DD/MM + MM/DD is unsupported by
        design — the parser picks whichever unambiguous signal it sees).

    **Tightest-span tiebreaker** (the "26-01-08" incident regression):
    when every row is ambiguous (all slots <= 12), parse the whole list
    under BOTH slash interpretations, count distinct YYYY-MM months,
    and pick the interpretation with STRICTLY fewer months. Equal (or
    both zero) -> None: don't guess; the caller falls back.
    """
    saw_iso = saw_ddmm = saw_mmdd = False
    for raw in dates:
        s = raw.strip()
        if not s:
            continue
        if _ISO_RE.match(s):
            saw_iso = True
            continue
        m = _SLASH_RE.match(s)
        if not m:
            continue
        a = int(m.group(1))
        b = int(m.group(2))
        if a > 12:
            saw_ddmm = True
        elif b > 12:
            saw_mmdd = True
    if saw_iso and not saw_ddmm and not saw_mmdd:
        return "YYYY-MM-DD"
    if saw_ddmm and not saw_mmdd:
        return "DD/MM/YYYY"
    if saw_mmdd and not saw_ddmm:
        return "MM/DD/YYYY"
    if saw_iso:
        return "YYYY-MM-DD"

    def distinct_months(fmt: str) -> int:
        months = set()
        for raw in dates:
            parsed = parse_date_only(raw, fmt)
            if parsed:
                months.add(parsed[:7])  # YYYY-MM
        return len(months)

    ddmm_months = distinct_months("DD/MM/YYYY")
    mmdd_months = distinct_months("MM/DD/YYYY")
    if ddmm_months == 0 and mmdd_months == 0:
        return None
    # strict less-than: if equal (or one is zero), don't guess
    if ddmm_months > 0 and (mmdd_months == 0 or ddmm_months < mmdd_months):
        return "DD/MM/YYYY"
    if mmdd_months > 0 and (ddmm_months == 0 or mmdd_months < ddmm_months):
        return "MM/DD/YYYY"
    return None


def parse_date_only(s: str, fmt: str) -> str | None:
    """-> zero-padded 'YYYY-MM-DD' or None (imports.md B20). ISO branch
    is strict (dashes only); the slash branch accepts / - . separators;
    2-digit years are 2000-series; validation is 1<=m<=12, 1<=d<=31
    (deliberately no per-month day check — parity), 2000<=y<=2100."""
    t = s.strip()
    if not t:
        return None
    if fmt == "YYYY-MM-DD":
        m = _ISO_CAPTURE_RE.match(t)
        if not m:
            return None
        year, month, day = int(m.group(1)), int(m.group(2)), int(m.group(3))
    else:
        m = _SLASH_RE.match(t)
        if not m:
            return None
        a, b, c = int(m.group(1)), int(m.group(2)), int(m.group(3))
        if fmt == "MM/DD/YYYY":
            month, day, year = a, b, c
        else:
            day, month, year = a, b, c
        if year < 100:
            year += 2000
    if month < 1 or month > 12 or day < 1 or day > 31 or year < 2000 or year > 2100:
        return None
    return f"{year:04d}-{month:02d}-{day:02d}"


def parse_time_only(s: str, _fmt: str | None = None) -> str | None:
    """-> 24h-normalized 'HH:MM:SS' or None (imports.md B21). Format is
    auto-detected PER STRING by the AM/PM suffix; the ``_fmt`` argument
    is accepted for port fidelity and IGNORED (CLK-026/027)."""
    t = s.strip()
    if not t:
        return None
    if _MERIDIEM_SUFFIX_RE.search(t):
        m = _TIME_12H_RE.match(t)
        if not m:
            return None
        hour = int(m.group(1))
        minute = int(m.group(2))
        sec = int(m.group(3)) if m.group(3) else 0
        ampm = m.group(4).upper()
        if hour < 1 or hour > 12 or minute > 59 or sec > 59:
            return None
        if ampm == "PM" and hour < 12:
            hour += 12
        if ampm == "AM" and hour == 12:
            hour = 0
        return f"{hour:02d}:{minute:02d}:{sec:02d}"
    m = _TIME_24H_RE.match(t)
    if not m:
        return None
    hour = int(m.group(1))
    minute = int(m.group(2))
    sec = int(m.group(3)) if m.group(3) else 0
    if hour > 23 or minute > 59 or sec > 59:
        return None
    return f"{hour:02d}:{minute:02d}:{sec:02d}"


def join_date_time(date: str | None, time: str | None) -> str | None:
    if not date or not time:
        return None
    return f"{date} {time}"


def parse_duration_seconds(s: str) -> int | None:
    """'H+:MM:SS' | 'H+:MM' | decimal hours -> seconds (imports.md B23)."""
    t = s.strip()
    if not t:
        return None
    m = _HMS_RE.match(t)
    if m:
        return int(m.group(1)) * 3600 + int(m.group(2)) * 60 + int(m.group(3))
    m = _HM_RE.match(t)
    if m:
        return int(m.group(1)) * 3600 + int(m.group(2)) * 60
    try:
        dec = float(t)
    except ValueError:
        return None
    if math.isfinite(dec) and dec >= 0:
        return _js_round(dec * 3600)
    return None


def parse_boolean(s: str) -> bool:
    return s.strip().lower() in ("yes", "true", "1", "y")


def parse_number_or_null(s: str) -> float | None:
    t = s.strip()
    if not t:
        return None
    try:
        n = float(t)
    except ValueError:
        return None
    return n if math.isfinite(n) else None


def _read_rows(csv_text: str) -> list[list[str]]:
    """papaparse `skipEmptyLines: true` semantics: rows that are empty
    arrays / a single empty field are dropped; whitespace-only rows
    survive here and are skipped by the row loop's all-blank check."""
    reader = csv_module.reader(io.StringIO(csv_text.strip()))
    rows = []
    for row in reader:
        if not row or (len(row) == 1 and row[0] == ""):
            continue
        rows.append(row)
    return rows


def _cell(row: list[str], idx: int) -> str:
    """JS row[idx] ?? '' — tolerate short rows."""
    if idx < 0 or idx >= len(row):
        return ""
    return row[idx] or ""


def parse_clockify_csv(
    csv_text: str,
    date_format: str = DEFAULT_DATE_FORMAT,
    _time_format: str | None = None,
) -> ParseResult:
    """The whole-file parse (imports.md B13–B29). ``date_format`` is a
    FALLBACK only — detection from the data overrides it; the ignored
    ``_time_format`` is kept so legacy call shapes port verbatim."""
    rows = _read_rows(csv_text)
    if not rows:
        return ParseResult(errors=[{"row": 0, "message": "CSV is empty."}])

    idx = _build_header_index(rows[0])
    missing = [key for key in REQUIRED_KEYS if idx[key] == -1]
    if missing:
        labels = ", ".join(HEADERS[key][0] for key in missing)
        return ParseResult(
            errors=[
                {
                    "row": 0,
                    "message": (
                        f"CSV is missing required columns: {labels}. Make sure you"
                        " exported with Clockify's default Detailed Report layout."
                    ),
                }
            ]
        )

    start_dates = [_cell(r, idx["start_date"]) for r in rows[1:]]
    start_dates = [s for s in start_dates if s]
    detected = detect_date_format(start_dates) or date_format

    result = ParseResult(total_rows=len(rows) - 1, date_format_used=detected)
    emails: set[str] = set()
    min_date: str | None = None
    max_date: str | None = None

    for i in range(1, len(rows)):
        r = rows[i]
        if all((c or "").strip() == "" for c in r):
            continue

        raw_start_date = _cell(r, idx["start_date"])
        raw_start_time = _cell(r, idx["start_time"])
        raw_end_date = _cell(r, idx["end_date"])
        raw_end_time = _cell(r, idx["end_time"])

        sd = parse_date_only(raw_start_date, detected)
        st = parse_time_only(raw_start_time)
        ed = parse_date_only(raw_end_date, detected)
        et = parse_time_only(raw_end_time)

        if not sd or not st:
            result.errors.append(
                {
                    "row": i + 1,
                    "message": (
                        f"Row {i + 1}: invalid start date/time"
                        f" ({raw_start_date} {raw_start_time})"
                    ),
                }
            )
            continue

        # T-IMP-011 rebuild half: invalid END is surfaced, row kept.
        if (not ed or not et) and (raw_end_date.strip() or raw_end_time.strip()):
            result.warnings.append(
                {
                    "row": i + 1,
                    "message": (
                        f"Row {i + 1}: invalid end date/time"
                        f" ({raw_end_date} {raw_end_time}) — end time left blank"
                    ),
                }
            )

        start_at = join_date_time(sd, st)
        end_at = join_date_time(ed, et)

        email = _cell(r, idx["email"]).strip().lower()
        user = _cell(r, idx["user"]).strip() if idx["user"] >= 0 else ""
        client, project = split_client_project(_cell(r, idx["project"]).strip())

        result.entries.append(
            ParsedEntry(
                source_user_name=user,
                source_user_email=email,
                operator=_cell(r, idx["client"]).strip(),
                client=client,
                project=project,
                description=_cell(r, idx["description"]).strip(),
                billable=(
                    parse_boolean(_cell(r, idx["billable"])) if idx["billable"] >= 0 else True
                ),
                start_at=start_at,  # type: ignore[arg-type]  # sd/st non-null here
                end_at=end_at,
                duration_seconds=parse_duration_seconds(_cell(r, idx["duration"])),
                source_rate=(
                    parse_number_or_null(_cell(r, idx["rate"])) if idx["rate"] >= 0 else None
                ),
                source_amount=(
                    parse_number_or_null(_cell(r, idx["amount"])) if idx["amount"] >= 0 else None
                ),
            )
        )

        if email:
            emails.add(email)
        if min_date is None or sd < min_date:
            min_date = sd
        if max_date is None or sd > max_date:
            max_date = sd

    result.unique_emails = sorted(emails)
    if min_date and max_date:
        result.date_range = {"min": min_date, "max": max_date}
    return result


MONTH_ABBR = ("Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec")


def format_date_range_label(date_range: dict | None) -> str | None:
    """CSV date range -> human batch-name label (imports.md B34–B36):
    'Jun 2, 2026' / 'Jun 2 – Jun 8, 2026' / 'Dec 28, 2025 – Jan 3, 2026'.
    The separator is an en dash with spaces, not a hyphen."""
    if not date_range:
        return None
    min_match = _DATE_LABEL_RE.match(date_range.get("min", ""))
    max_match = _DATE_LABEL_RE.match(date_range.get("max", ""))
    if not min_match or not max_match:
        return None
    min_y, min_m, min_d = min_match.groups()
    max_y, max_m, max_d = max_match.groups()
    min_month = MONTH_ABBR[int(min_m) - 1]
    max_month = MONTH_ABBR[int(max_m) - 1]
    min_day = str(int(min_d))
    max_day = str(int(max_d))
    if (min_y, min_m, min_d) == (max_y, max_m, max_d):
        return f"{min_month} {min_day}, {min_y}"
    if min_y == max_y:
        return f"{min_month} {min_day} – {max_month} {max_day}, {min_y}"
    return f"{min_month} {min_day}, {min_y} – {max_month} {max_day}, {max_y}"
