"""pvlint core — parse a .tidal track into the shapes the rules reason about.

Why this exists (2026-07-28, J-7 to OPAL)
-----------------------------------------
Three separate evenings were lost to bugs that were *visible in the text* and
that no human eye caught mid-set:

  * `desire.tidal` -> `off 0.125 (|+ note 12 . (|* gain 0.6))` parsed as a
    function composed into a ValueMap. One character. NINE orbits silent, and
    because a blank line is Tidal's block separator, the whole track failed to
    compile while looking perfectly fine on screen.
  * `you_my_sunshine.tidal` -> a local `let gF3 = (# djfbus 3 (range 0.05 0.95
    "^51"))` shadowing the fixed global helper. `djf` 0.05 is a ~26 Hz
    low-pass, i.e. silence, and that is where a low knob rests. FOUR orbits
    (d5/d7/d9/d11) inaudible until a knob crossed centre.
  * An untouched `"^NN"` yields NO events, so `range a b "^NN"` is `silence`,
    not a number — the #55 mute-bomb.

Every one of these is a STATIC property of the file. None of them produce an
error message; they produce silence, on stage, with a crowd waiting. So they
belong in a linter, not in a runbook.

The parse model
---------------
A `.tidal` file is a sequence of BLOCKS separated by blank lines. Tidal
evaluates a block atomically: one syntax error anywhere in a block takes the
entire block down, which is why "9 orbits went silent" is a *compile* symptom
and not an audio one. Inside a block, an orbit is declared by `dN $ ...` at
column 0 and continues until the next such declaration or the end of the block.

That three-level model (file -> block -> orbit) is all the rules need.
"""

from __future__ import annotations

import re
from dataclasses import dataclass, field
from typing import Iterator

SEVERITIES = ("error", "warning", "info")

# `d1 $ ...` at column 0 — but the `$` need NOT be on the same line. Haskell's
# layout rule makes this legal, and PLN writes it live all the time:
#
#     d5  -- The Voice of Love
#       $ midiOn "^58" (...)
#
# An earlier version of this regex required a same-line `$` and silently dropped
# d5 from the orbit inventory of a track that was playing it — a linter that
# under-reports is worse than none, so the lookahead accepts `$`, a comment, or
# end-of-line.
ORBIT_RE = re.compile(r"^(d(\d{1,2}))(?=\s*(?:\$|--|$))")
# A Haskell line comment. Not string-aware on purpose: `--` inside a mini-notation
# string is not valid Tidal anyway, and being conservative here costs nothing.
COMMENT_RE = re.compile(r"--.*$")


@dataclass
class Finding:
    """One lint result, anchored to a 1-indexed line."""

    rule: str
    severity: str
    line: int
    message: str
    detail: str = ""
    # Autofix: replace line `line` (1-indexed) entirely with `fix`. None = no fix.
    # A fix of "" means "delete this line".
    fix: str | None = None

    def __post_init__(self) -> None:
        if self.severity not in SEVERITIES:
            raise ValueError(f"bad severity {self.severity!r}")


@dataclass
class Orbit:
    """One `dN $ ...` declaration and its continuation lines."""

    number: int
    start: int  # 1-indexed line of the `dN $`
    lines: list[str] = field(default_factory=list)

    @property
    def text(self) -> str:
        return "\n".join(self.lines)

    def code(self) -> str:
        """The orbit's text with comments stripped — what Tidal actually sees."""
        return "\n".join(strip_comment(ln) for ln in self.lines)


@dataclass
class Block:
    """A blank-line-delimited unit. Tidal compiles this atomically."""

    start: int  # 1-indexed line of the first line
    lines: list[str] = field(default_factory=list)

    @property
    def text(self) -> str:
        return "\n".join(self.lines)


@dataclass
class Track:
    path: str
    text: str

    @property
    def lines(self) -> list[str]:
        return self.text.splitlines()

    def blocks(self) -> list[Block]:
        out: list[Block] = []
        cur: Block | None = None
        for i, ln in enumerate(self.lines, start=1):
            if ln.strip() == "":
                cur = None
                continue
            if cur is None:
                cur = Block(start=i)
                out.append(cur)
            cur.lines.append(ln)
        return out

    def orbits(self) -> list[Orbit]:
        """Every `dN $` declaration in the file, with its continuation lines.

        An orbit ends at the next column-0 `dN $`, at a blank line, or at EOF.
        Later declarations of the same N are kept separately: re-declaring `d5`
        in one file is itself worth flagging, and silently merging them would
        hide it.
        """
        out: list[Orbit] = []
        cur: Orbit | None = None
        for i, ln in enumerate(self.lines, start=1):
            m = ORBIT_RE.match(ln)
            if m:
                cur = Orbit(number=int(m.group(2)), start=i, lines=[ln])
                out.append(cur)
                continue
            if ln.strip() == "":
                cur = None
                continue
            if cur is not None:
                cur.lines.append(ln)
        return out

    def declared_orbits(self) -> set[int]:
        return {o.number for o in self.orbits()}


def strip_comment(line: str) -> str:
    """Remove a trailing Haskell comment.

    Deliberately naive: we do not attempt to respect `--` inside a string
    literal. Tidal mini-notation has no legitimate `--`, and a false strip can
    only ever cause a MISSED finding, never a wrong autofix (autofixing rules
    re-match against the raw line).
    """
    return COMMENT_RE.sub("", line)


def iter_code_lines(track: Track) -> Iterator[tuple[int, str]]:
    """(1-indexed line number, comment-stripped text) for non-blank lines."""
    for i, ln in enumerate(track.lines, start=1):
        code = strip_comment(ln)
        if code.strip():
            yield i, code


def load(path: str) -> Track:
    with open(path, "r", encoding="utf-8", errors="replace") as fh:
        return Track(path=path, text=fh.read())
