# Copyright (C) 2026 GoodData Corporation

"""Helpers for `aida-mcp commit` command."""

from __future__ import annotations

import re
import subprocess
from dataclasses import dataclass
from pathlib import Path

from aida_mcp.tools.core.git_policy_config import CommitPolicy
from aida_mcp.tools.core.template_loader import build_commit_template_variables, render_commit_message_template

_CO_AUTHOR_PREFIX_RE = re.compile(r"^co-authored-by:\s*", flags=re.IGNORECASE)
_CO_AUTHOR_VALUE_RE = re.compile(r"^.+ <[^<>\s@]+@[^<>\s@]+>$")
_FOOTER_LINE_RE = re.compile(r"^[A-Za-z][A-Za-z0-9-]*:\s+.+$")


@dataclass(frozen=True, slots=True)
class CommitMessageInput:
    commit_type: str
    scope: str | None
    title: str
    body: str | None
    risk: str | None
    tickets: list[str]
    co_authored_by: list[str]


def render_commit_message(
    data: CommitMessageInput, *, policy: CommitPolicy | None = None, workspace_root: Path | None = None
) -> str:
    active_policy = policy or CommitPolicy()
    title = data.title.strip()
    if not title:
        raise ValueError("Commit title must not be empty.")
    if "\n" in title:
        raise ValueError("Commit title must be a single line.")

    subject = _build_subject(data.commit_type, data.scope, title)
    if len(subject) > active_policy.subject_max_chars:
        raise ValueError(f"Commit title line is too long ({len(subject)} > {active_policy.subject_max_chars}).")

    risk = (data.risk or "").strip().lower()
    if active_policy.risk_enabled:
        if not risk:
            raise ValueError("Risk trailer is required by git policy; pass --risk.")
        if risk not in set(active_policy.risk_values):
            allowed = "|".join(active_policy.risk_values)
            raise ValueError(f"Unsupported risk value '{data.risk}'. Allowed: {allowed}.")

    normalized_co_authors: list[str] = [_normalize_co_author_value(entry) for entry in data.co_authored_by]
    if active_policy.require_co_authored_by and not normalized_co_authors:
        raise ValueError("Co-authored-by trailer is required by git policy; pass --co-authored-by.")

    ticket_items = [item.strip() for item in data.tickets if item.strip()]
    body = (data.body or "").strip()

    if active_policy.template_file:
        if workspace_root is None:
            raise ValueError("workspace_root is required when commit template_file is configured.")
        variables = build_commit_template_variables(
            commit_type=data.commit_type,
            scope=data.scope,
            title=title,
            body=body,
            risk=risk,
            ticket_items=ticket_items,
            co_authored_by_items=normalized_co_authors,
        )
        rendered = render_commit_message_template(
            workspace_root=workspace_root,
            policy=active_policy,
            variables=variables,
        )
        if rendered is None:
            raise ValueError("Commit template_file is configured but could not be rendered.")
        message = rendered.rstrip() + "\n"
        errors = validate_commit_message(message, policy=active_policy)
        if errors:
            raise ValueError("Rendered commit message violates policy: " + "; ".join(errors))
        return message

    lines: list[str] = [subject, ""]
    if body:
        lines.append(body)
        lines.append("")

    footer_lines: list[str] = []
    for entry in normalized_co_authors:
        footer_lines.append(f"Co-authored-by: {entry}")
    if active_policy.ticket_enabled and ticket_items:
        footer_lines.append(f"{active_policy.ticket_prefix}: {', '.join(ticket_items)}")

    if active_policy.risk_enabled:
        # Keep risk as the last footer line by policy.
        footer_lines.append(f"{active_policy.risk_prefix}: {risk}")
    lines.extend(footer_lines)

    return "\n".join(lines).rstrip() + "\n"


def validate_commit_message(message: str, *, policy: CommitPolicy | None = None) -> list[str]:
    """Return policy violations for an existing commit message."""
    active_policy = policy or CommitPolicy()
    errors: list[str] = []
    lines = message.splitlines()
    if not lines:
        return ["Commit message is empty."]

    subject = lines[0].strip()
    if not subject:
        errors.append("Commit title must not be empty.")
        return errors
    if len(subject) > active_policy.subject_max_chars:
        errors.append(f"Commit title line is too long ({len(subject)} > {active_policy.subject_max_chars}).")

    footer_lines = _extract_trailing_footers(lines)
    footer_map: dict[str, list[str]] = {}
    for line in footer_lines:
        key, value = line.split(":", 1)
        footer_map.setdefault(key.strip().lower(), []).append(value.strip())

    ticket_key = active_policy.ticket_prefix.lower()
    risk_key = active_policy.risk_prefix.lower()
    risk_values = footer_map.get(risk_key, [])
    if active_policy.risk_enabled and not risk_values:
        errors.append(f"does not contain a valid {active_policy.risk_prefix} trailer")
    elif active_policy.risk_enabled:
        risk = risk_values[-1].lower()
        if risk not in set(active_policy.risk_values):
            allowed = "|".join(active_policy.risk_values)
            errors.append(f"Unsupported risk value '{risk}'. Allowed: {allowed}.")
        if not footer_lines or not footer_lines[-1].lower().startswith(f"{risk_key}:"):
            errors.append(f"{active_policy.risk_prefix} trailer must be the last footer line")
        errors.extend(
            _validate_footer_order(
                footer_lines,
                ticket_key=ticket_key,
                ticket_label=active_policy.ticket_prefix,
                risk_key=risk_key,
                risk_label=active_policy.risk_prefix,
                ticket_enabled=active_policy.ticket_enabled,
                risk_enabled=active_policy.risk_enabled,
            )
        )
    elif active_policy.ticket_enabled:
        errors.extend(
            _validate_footer_order(
                footer_lines,
                ticket_key=ticket_key,
                ticket_label=active_policy.ticket_prefix,
                risk_key=risk_key,
                risk_label=active_policy.risk_prefix,
                ticket_enabled=active_policy.ticket_enabled,
                risk_enabled=active_policy.risk_enabled,
            )
        )

    if active_policy.require_co_authored_by and "co-authored-by" not in footer_map:
        errors.append("Co-authored-by trailer is required by git policy")

    return errors


def commit_with_message(
    *,
    workspace_root: Path,
    message: str,
) -> tuple[Path, list[str]]:
    repo_root = _resolve_git_root(workspace_root)
    if should_stage_all_changes(workspace_root):
        _run_git(["add", "-A"], cwd=repo_root)

    staged_files = _staged_files(repo_root)
    if not staged_files:
        raise ValueError("No changes to commit.")

    _run_git(["commit", "-m", message], cwd=repo_root)
    return repo_root, staged_files


def commit_with_fixup(
    *,
    workspace_root: Path,
    fixup_of: str,
    body: str | None = None,
) -> tuple[Path, list[str]]:
    repo_root = _resolve_git_root(workspace_root)
    if should_stage_all_changes(workspace_root):
        _run_git(["add", "-A"], cwd=repo_root)

    staged_files = _staged_files(repo_root)
    if not staged_files:
        raise ValueError("No changes to commit.")

    argv = ["commit", f"--fixup={fixup_of.strip()}"]
    for paragraph in _body_to_commit_paragraphs(body):
        argv.extend(["-m", paragraph])
    _run_git(argv, cwd=repo_root)
    return repo_root, staged_files


def should_stage_all_changes(workspace_root: Path) -> bool:
    repo_root = _resolve_git_root(workspace_root)
    return _has_unstaged_or_untracked_changes(repo_root)


def _body_to_commit_paragraphs(body: str | None) -> list[str]:
    text = (body or "").strip("\n").strip()
    if not text:
        return []
    return [part for part in re.split(r"\n\s*\n", text) if part.strip()]


def _build_subject(commit_type: str, scope: str | None, title: str) -> str:
    ctype = commit_type.strip()
    if not ctype:
        raise ValueError("Commit type must not be empty.")
    if any(ch.isspace() for ch in ctype):
        raise ValueError("Commit type must not contain spaces.")
    scope_normalized = (scope or "").strip()
    if scope_normalized:
        return f"{ctype}({scope_normalized}): {title}"
    return f"{ctype}: {title}"


def _normalize_co_author_value(raw_value: str) -> str:
    value = raw_value.strip()
    if not value:
        raise ValueError("Empty co-author value.")
    value = _CO_AUTHOR_PREFIX_RE.sub("", value).strip()
    if not _CO_AUTHOR_VALUE_RE.fullmatch(value):
        raise ValueError(f"Invalid co-author format '{raw_value}'. Expected: Name <email>.")
    return value


def _extract_trailing_footers(lines: list[str]) -> list[str]:
    last = len(lines) - 1
    while last >= 0 and not lines[last].strip():
        last -= 1
    if last < 0:
        return []

    footers_reversed: list[str] = []
    idx = last
    while idx >= 0:
        line = lines[idx].strip()
        if not line:
            break
        if not _FOOTER_LINE_RE.fullmatch(line):
            break
        footers_reversed.append(line)
        idx -= 1
    return list(reversed(footers_reversed))


def _validate_footer_order(
    footer_lines: list[str],
    *,
    ticket_key: str,
    ticket_label: str,
    risk_key: str,
    risk_label: str,
    ticket_enabled: bool,
    risk_enabled: bool,
) -> list[str]:
    errors: list[str] = []
    if not footer_lines:
        return errors

    keys = [line.split(":", 1)[0].strip().lower() for line in footer_lines]
    risk_index = None
    if risk_enabled:
        try:
            risk_index = len(keys) - 1 - keys[::-1].index(risk_key)
        except ValueError:
            return errors

    # Ticket trailers (if present) must be the block immediately before risk.
    ticket_block_start = len(keys)
    if ticket_enabled and ticket_key in keys:
        idx = (risk_index - 1) if risk_index is not None else len(keys) - 1
        if idx < 0 or keys[idx] != ticket_key:
            if risk_enabled:
                errors.append(f"{ticket_label} trailer must be immediately before {risk_label} trailer")
            else:
                errors.append(f"{ticket_label} trailer must be the last metadata footer block")
        else:
            while idx >= 0 and keys[idx] == ticket_key:
                idx -= 1
            ticket_block_start = idx + 1
        if any(key == ticket_key for key in keys[:ticket_block_start]):
            errors.append(f"{ticket_label} trailers must form a single block before {risk_label} trailer")
    elif risk_index is not None:
        ticket_block_start = risk_index

    # Co-authored-by trailers (if present) must be the block immediately before ticket/risk.
    if "co-authored-by" in keys:
        idx = ticket_block_start - 1
        if idx < 0 or keys[idx] != "co-authored-by":
            if risk_enabled or ticket_enabled:
                label = f"{ticket_label}/{risk_label}" if risk_enabled else ticket_label
                errors.append(f"Co-authored-by trailer must be immediately before {label} trailers")
        else:
            while idx >= 0 and keys[idx] == "co-authored-by":
                idx -= 1
        if any(key == "co-authored-by" for key in keys[: idx + 1]):
            errors.append(
                f"Co-authored-by trailers must form a single block before {ticket_label}/{risk_label} trailers"
            )

    return errors


def _resolve_git_root(path: Path) -> Path:
    completed = _run_git(["rev-parse", "--show-toplevel"], cwd=path)
    return Path(completed.stdout.strip()).resolve()


def _staged_files(repo_root: Path) -> list[str]:
    completed = _run_git(["diff", "--cached", "--name-only"], cwd=repo_root)
    return [line.strip() for line in completed.stdout.splitlines() if line.strip()]


def _has_unstaged_or_untracked_changes(repo_root: Path) -> bool:
    completed = _run_git(["status", "--short"], cwd=repo_root)
    for line in completed.stdout.splitlines():
        if not line:
            continue
        if line.startswith("??"):
            return True
        if len(line) >= 2 and line[1] != " ":
            return True
    return False


def _run_git(argv: list[str], *, cwd: Path) -> subprocess.CompletedProcess[str]:
    cmd = ["git", *argv]
    completed = subprocess.run(cmd, cwd=cwd, text=True, capture_output=True, check=False)
    if completed.returncode != 0:
        stderr = completed.stderr.strip()
        stdout = completed.stdout.strip()
        details = stderr or stdout or f"git {' '.join(argv)} failed"
        raise RuntimeError(details)
    return completed
