"""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


_PLAN_INDEX = """# Plan index

> **Role**: `.agent/project/plan/` holds **planning artifacts** (roadmaps, milestones, execution plans).
> **Entry point for AI/MCP**: Always update this file when adding a new plan markdown so agents can discover it.

## How to use

1. **Write** new plans as `plan/<name>.md` (kebab-case name).
2. **Register** each plan in the table below (path relative to `project/plan/`).
3. **Read** this index first when scoping work or answering "what's planned".

## Index

| Plan | File | Summary | Status |
|------|------|---------|--------|
| *(add rows as you create plans)* | | | |

---

*Generated/maintained by cokodo-agent — run `co scaffold` to create missing dirs.*
"""

_RESEARCH_INDEX = """# Research index

> **Role**: `.agent/project/research/` holds **research and survey reports** (tooling comparisons, spike notes, ADR inputs).
> **Entry point for AI/MCP**: Always update this file when adding a new report so agents can discover it.

## How to use

1. **Write** new reports as `research/<topic>-<yyyy-mm>.md` or `research/<name>.md`.
2. **Register** each report in the table below (path relative to `project/research/`).
3. **Read** this index before deep-diving so you load the right report.

## Index

| Report | File | Topic | Date |
|--------|------|-------|------|
| *(add rows as you add reports)* | | | |

---

*Generated/maintained by cokodo-agent — run `co scaffold` to create missing dirs.*
"""


def ensure_plan_research_dirs(agent_dir: Path) -> list[str]:
    """Create project/plan/ and project/research/ with index markdown if missing.

    Does not overwrite existing index files (so manual edits are preserved).
    Creates directories even if index already exists elsewhere.

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

    plan_dir = project_dir / "plan"
    plan_dir.mkdir(parents=True, exist_ok=True)
    plan_index = plan_dir / "plan-index.md"
    if not plan_index.exists():
        plan_index.write_text(_PLAN_INDEX, encoding="utf-8")
        created.append(str(plan_index.relative_to(root)).replace("\\", "/"))

    research_dir = project_dir / "research"
    research_dir.mkdir(parents=True, exist_ok=True)
    research_index = research_dir / "research-index.md"
    if not research_index.exists():
        research_index.write_text(_RESEARCH_INDEX, encoding="utf-8")
        created.append(str(research_index.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
