"""SDD change unit management (v1.9.0).

Implements change proposal lifecycle under .agent/project/changes/<name>/
with proposal.md, specs.md, design.md, tasks.md — aligned with ADR-009.
"""

import re
from pathlib import Path
from typing import NamedTuple


class ChangeInfo(NamedTuple):
    """Summary of one change directory."""

    name: str
    path: Path
    tasks_total: int
    tasks_done: int
    has_proposal: bool
    has_specs: bool
    has_design: bool


# Required files inside each change directory (standard schema)
CHANGE_ARTIFACTS = ("proposal.md", "specs.md", "design.md", "tasks.md")
CHANGE_ARTIFACTS_MINIMAL = ("specs.md", "tasks.md")

# Built-in schemas: standard = four artifacts; minimal = specs + tasks only
SCHEMA_STANDARD = "standard"
SCHEMA_MINIMAL = "minimal"
BUILTIN_SCHEMAS = {
    SCHEMA_STANDARD: CHANGE_ARTIFACTS,
    SCHEMA_MINIMAL: CHANGE_ARTIFACTS_MINIMAL,
}

# Marker file: single line = active change name (relative to project/changes/)
ACTIVE_CHANGE_FILE = ".active"

# Archived changes live under .agent/archive/changes/<name>/ (moved from project/changes/)
def _archive_changes_root(agent_dir: Path) -> Path:
    return agent_dir / "archive" / "changes"

_README_SPECS = """# Specs (Living Documentation)

This directory holds **functional specifications by domain** — the current
truth for what the system SHALL do. Organize by capability, e.g.:

```
specs/
├── README.md
├── auth/
│   └── spec.md
└── checkout/
    └── spec.md
```

Each `spec.md` should use requirements and scenarios (GIVEN/WHEN/THEN).
Create domains as needed; there is no required initial structure beyond this file.

See also: `../changes/` for active change proposals.
"""

_README_CHANGES = """# Changes (Active Proposals)

Each subdirectory is one **change unit** with a full SDD artifact set:

- `proposal.md` — why and what scope
- `specs.md` — requirements delta for this change
- `design.md` — technical approach
- `tasks.md` — implementation checklist (AI-friendly checkboxes)

Create a new change:

```bash
co change new <kebab-case-name>
```

List and status:

```bash
co change list
co change status <name>
```

Set which change AI should focus on (writes `.active` + optional `status.md` section):

```bash
co change apply <name>
co change clear
```

Minimal schema (only specs + tasks):

```bash
co change new <name> --schema minimal
co change schema list
```

When a change is complete, run `co change archive <name>` to move it to
`.agent/archive/changes/<name>/` (optional `--merge-specs` copies specs delta into
`project/specs/_archive/` for traceability).
"""


def _changes_root(agent_dir: Path) -> Path:
    return agent_dir / "project" / "changes"


def _specs_root(agent_dir: Path) -> Path:
    return agent_dir / "project" / "specs"


def ensure_sdd_dirs(agent_dir: Path) -> list[str]:
    """Create project/changes and project/specs with README if missing.

    Returns list of paths relative to agent_dir.parent (project root),
    e.g. .agent/project/specs/README.md
    """
    created: list[str] = []
    root = agent_dir.parent
    project_dir = agent_dir / "project"
    project_dir.mkdir(parents=True, exist_ok=True)

    specs_dir = _specs_root(agent_dir)
    if not specs_dir.exists():
        specs_dir.mkdir(parents=True, exist_ok=True)
        (specs_dir / "README.md").write_text(_README_SPECS, encoding="utf-8")
        created.append(str((specs_dir / "README.md").relative_to(root)).replace("\\", "/"))

    changes_dir = _changes_root(agent_dir)
    if not changes_dir.exists():
        changes_dir.mkdir(parents=True, exist_ok=True)
        (changes_dir / "README.md").write_text(_README_CHANGES, encoding="utf-8")
        created.append(str((changes_dir / "README.md").relative_to(root)).replace("\\", "/"))

    return created


def _validate_change_name(name: str) -> str:
    """Normalize and validate change id (kebab-case, no path traversal)."""
    name = name.strip().lower().replace(" ", "-")
    if not name or ".." in name or "/" in name or "\\" in name:
        raise ValueError("Change name must be a single kebab-case segment (no path chars).")
    if not re.match(r"^[a-z0-9][a-z0-9-]*$", name):
        raise ValueError(
            "Change name must start with letter or digit and contain only "
            "lowercase letters, digits, and hyphens."
        )
    return name


def _template_proposal(change_name: str) -> str:
    return f"""# Proposal: {change_name}

## Why

[Describe the problem or opportunity.]

## Scope

[What is in scope / out of scope.]

## Success criteria

- [ ] [Criterion 1]
- [ ] [Criterion 2]
"""


def _template_specs(change_name: str) -> str:
    return f"""# Specs delta: {change_name}

## Requirements

### Requirement: [Title]

[The system SHALL ...]

#### Scenario: [Name]

- GIVEN [context]
- WHEN [action]
- THEN [outcome]

"""


def _template_design(change_name: str) -> str:
    return f"""# Design: {change_name}

## Approach

[High-level technical approach.]

## Alternatives considered

- [Option A] — rejected because ...
- [Option B] — chosen because ...

## Risks

- [Risk] — mitigation: ...
"""


def _template_tasks(change_name: str) -> str:
    return f"""# Tasks: {change_name}

Implementation checklist. Mark done with `[x]`.

- [ ] Task 1
- [ ] Task 2
- [ ] Task 3
"""


def get_active_change(agent_dir: Path) -> str | None:
    """Return active change name if set, else None."""
    p = _changes_root(agent_dir) / ACTIVE_CHANGE_FILE
    if not p.is_file():
        return None
    name = p.read_text(encoding="utf-8").strip()
    return name or None


def set_active_change(agent_dir: Path, name: str) -> None:
    """Set active change; validates directory exists."""
    name = _validate_change_name(name)
    change_dir = _changes_root(agent_dir) / name
    if not change_dir.is_dir():
        raise FileNotFoundError(f"Change not found: {name}")
    ensure_sdd_dirs(agent_dir)
    (_changes_root(agent_dir) / ACTIVE_CHANGE_FILE).write_text(name + "\n", encoding="utf-8")


def clear_active_change(agent_dir: Path) -> None:
    """Remove active change marker."""
    p = _changes_root(agent_dir) / ACTIVE_CHANGE_FILE
    if p.exists():
        p.unlink()


def _patch_status_md_active_change(status_path: Path, change_name: str | None) -> bool:
    """Insert or replace ## Active change (SDD) section in status.md. Returns True if file written."""
    if not status_path.exists():
        return False
    content = status_path.read_text(encoding="utf-8")
    section_header = "## Active change (SDD)"
    if change_name:
        block = (
            f"{section_header}\n\n"
            f"**Current**: `{change_name}` — working directory: "
            f"`project/changes/{change_name}/`\n\n"
            f"AI should read `tasks.md` first, then `design.md` / `specs.md` as needed. "
            f"Clear with: `co change clear`\n\n"
        )
    else:
        block = f"{section_header}\n\n*(none — run `co change apply <name>` to set)*\n\n"

    if section_header in content:
        # Replace from header through next ## at line start (same or higher level)
        pattern = re.compile(
            r"^## Active change \(SDD\)\s*\n(?:.*\n)*?(?=^## |\Z)",
            re.MULTILINE,
        )
        new_content, n = pattern.subn(block, content, count=1)
        if n == 0:
            return False
    else:
        # Insert after "## Current Phase" block (after first --- following it) or at end
        insert_after = "## Session Context"
        if insert_after in content:
            idx = content.find(insert_after)
            new_content = content[:idx] + block + content[idx:]
        else:
            new_content = content.rstrip() + "\n\n" + block
    status_path.write_text(new_content, encoding="utf-8")
    return True


def change_apply(
    agent_dir: Path,
    name: str,
    *,
    update_status_md: bool = True,
) -> Path:
    """Set active change and optionally patch project/status.md."""
    set_active_change(agent_dir, name)
    if update_status_md:
        status_path = agent_dir / "project" / "status.md"
        _patch_status_md_active_change(status_path, name)
    return _changes_root(agent_dir) / name


def change_new(
    agent_dir: Path,
    name: str,
    force: bool = False,
    schema: str = SCHEMA_STANDARD,
) -> Path:
    """Create .agent/project/changes/<name>/ with standard artifacts.

    Returns path to the change directory.
    """
    name = _validate_change_name(name)
    ensure_sdd_dirs(agent_dir)
    change_dir = _changes_root(agent_dir) / name
    if change_dir.exists() and not force:
        raise FileExistsError(f"Change already exists: {change_dir}")
    if change_dir.exists() and force:
        import shutil

        shutil.rmtree(change_dir)
    change_dir.mkdir(parents=True, exist_ok=True)
    schema = schema.strip().lower()
    if schema not in BUILTIN_SCHEMAS:
        raise ValueError(f"Unknown schema '{schema}'. Use: {', '.join(BUILTIN_SCHEMAS)}")
    artifacts = BUILTIN_SCHEMAS[schema]
    if "proposal.md" in artifacts:
        (change_dir / "proposal.md").write_text(_template_proposal(name), encoding="utf-8")
    if "specs.md" in artifacts:
        (change_dir / "specs.md").write_text(_template_specs(name), encoding="utf-8")
    if "design.md" in artifacts:
        (change_dir / "design.md").write_text(_template_design(name), encoding="utf-8")
    if "tasks.md" in artifacts:
        (change_dir / "tasks.md").write_text(_template_tasks(name), encoding="utf-8")
    (change_dir / ".schema").write_text(schema + "\n", encoding="utf-8")
    return change_dir


def _count_tasks(tasks_path: Path) -> tuple[int, int]:
    """Return (total, done) from tasks.md checkbox lines."""
    if not tasks_path.exists():
        return 0, 0
    text = tasks_path.read_text(encoding="utf-8")
    total = 0
    done = 0
    for line in text.splitlines():
        stripped = line.strip()
        if re.match(r"^- \[[ xX]\]", stripped):
            total += 1
            if re.match(r"^- \[[xX]\]", stripped):
                done += 1
    return total, done


def change_list(agent_dir: Path) -> list[ChangeInfo]:
    """List all change directories under project/changes/."""
    changes_dir = _changes_root(agent_dir)
    if not changes_dir.exists():
        return []
    out: list[ChangeInfo] = []
    for entry in sorted(changes_dir.iterdir()):
        if not entry.is_dir() or entry.name.startswith("."):
            continue
        tasks_path = entry / "tasks.md"
        total, done = _count_tasks(tasks_path)
        out.append(
            ChangeInfo(
                name=entry.name,
                path=entry,
                tasks_total=total,
                tasks_done=done,
                has_proposal=(entry / "proposal.md").exists(),
                has_specs=(entry / "specs.md").exists(),
                has_design=(entry / "design.md").exists(),
            )
        )
    return out


def change_archive(
    agent_dir: Path,
    name: str,
    *,
    merge_specs: bool = False,
) -> Path:
    """Move project/changes/<name> to .agent/archive/changes/<name>.

    If the change was active, clears .active and status.md section.
    If merge_specs and specs.md exists, copies to project/specs/_archive/<name>-specs-delta.md.
    Returns path to archived directory.
    """
    import shutil
    from datetime import datetime

    name = _validate_change_name(name)
    change_dir = _changes_root(agent_dir) / name
    if not change_dir.is_dir():
        raise FileNotFoundError(f"Change not found: {name}")

    if get_active_change(agent_dir) == name:
        clear_active_change(agent_dir)
        status_path = agent_dir / "project" / "status.md"
        if status_path.exists():
            _patch_status_md_active_change(status_path, None)

    if merge_specs:
        specs_file = change_dir / "specs.md"
        if specs_file.exists():
            archive_specs_dir = _specs_root(agent_dir) / "_archive"
            archive_specs_dir.mkdir(parents=True, exist_ok=True)
            out = archive_specs_dir / f"{name}-specs-delta.md"
            header = (
                f"# Archived specs delta: {name}\n\n"
                f"> Archived at {datetime.now().strftime('%Y-%m-%d %H:%M')}\n\n"
                f"---\n\n"
            )
            out.write_text(header + specs_file.read_text(encoding="utf-8"), encoding="utf-8")

    dest_root = _archive_changes_root(agent_dir)
    dest_root.mkdir(parents=True, exist_ok=True)
    dest = dest_root / name
    if dest.exists():
        # collision: suffix with timestamp
        dest = dest_root / f"{name}-{datetime.now().strftime('%Y%m%d%H%M%S')}"
    shutil.move(str(change_dir), str(dest))
    return dest


def change_list_archived(agent_dir: Path) -> list[str]:
    """List archived change directory names."""
    root = _archive_changes_root(agent_dir)
    if not root.is_dir():
        return []
    return sorted(p.name for p in root.iterdir() if p.is_dir())


def change_status(agent_dir: Path, name: str) -> ChangeInfo | None:
    """Return ChangeInfo for a single change, or None if missing."""
    name = _validate_change_name(name)
    change_dir = _changes_root(agent_dir) / name
    if not change_dir.is_dir():
        return None
    tasks_path = change_dir / "tasks.md"
    total, done = _count_tasks(tasks_path)
    return ChangeInfo(
        name=name,
        path=change_dir,
        tasks_total=total,
        tasks_done=done,
        has_proposal=(change_dir / "proposal.md").exists(),
        has_specs=(change_dir / "specs.md").exists(),
        has_design=(change_dir / "design.md").exists(),
    )


def validate_change_directory(change_dir: Path) -> list[tuple[str, str]]:
    """Return list of (artifact, message) for missing required files."""
    if not change_dir.is_dir():
        return [("change-dir", "Not a directory")]
    schema_file = change_dir / ".schema"
    if schema_file.exists():
        schema = schema_file.read_text(encoding="utf-8").strip().lower()
        artifacts = BUILTIN_SCHEMAS.get(schema, CHANGE_ARTIFACTS)
    else:
        artifacts = CHANGE_ARTIFACTS
    missing: list[tuple[str, str]] = []
    for artifact in artifacts:
        if not (change_dir / artifact).exists():
            missing.append((artifact, f"Missing {artifact}"))
    return missing
