from __future__ import annotations

import json
import os
import re
import tempfile
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Mapping
from urllib.parse import urlparse

import requests
from http.cookiejar import MozillaCookieJar


_SAFE_NAME_RE = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$")


def _atomic_write_text(path: Path, text: str, *, encoding: str = "utf-8") -> None:
    path.parent.mkdir(parents=True, exist_ok=True)
    with tempfile.NamedTemporaryFile("w", delete=False, encoding=encoding, dir=str(path.parent)) as tmp:
        tmp.write(text)
        tmp_path = Path(tmp.name)
    tmp_path.replace(path)


def default_loki_home() -> Path:
    env = os.environ.get("LOKI_HOME")
    if env:
        return Path(env).expanduser().resolve()

    if os.name == "nt":
        appdata = os.environ.get("APPDATA")
        if appdata:
            return Path(appdata) / "loki"

    return Path.home() / ".loki"


def _validate_name(kind: str, value: str) -> str:
    if not _SAFE_NAME_RE.match(value):
        raise ValueError(f"Invalid {kind} name: {value!r}")
    return value


@dataclass(frozen=True, slots=True)
class ProfileRef:
    platform: str
    name: str = "default"

    def __post_init__(self) -> None:
        _validate_name("platform", self.platform)
        _validate_name("profile", self.name)


class Profile:
    def __init__(self, root: Path, ref: ProfileRef) -> None:
        self.root = root
        self.ref = ref

    @property
    def dir(self) -> Path:
        return self.root / "profiles" / self.ref.platform / self.ref.name

    @property
    def config_path(self) -> Path:
        return self.dir / "config.json"

    @property
    def cookies_path(self) -> Path:
        return self.dir / "cookies.txt"

    def _cookies_file_text(self) -> str | None:
        try:
            return self.cookies_path.read_text(encoding="utf-8")
        except FileNotFoundError:
            return None

    @staticmethod
    def _looks_like_netscape_cookie_file(text: str) -> bool:
        stripped = text.lstrip()
        if stripped.startswith("# Netscape HTTP Cookie File"):
            return True
        for line in stripped.splitlines():
            line = line.strip()
            if not line or line.startswith("#"):
                continue
            parts = line.split("\t")
            return len(parts) >= 7
        return False

    @staticmethod
    def _parse_cookie_header(text: str) -> dict[str, str]:
        cookies: dict[str, str] = {}
        for part in text.replace("\n", " ").split(";"):
            part = part.strip()
            if not part or "=" not in part:
                continue
            name, value = part.split("=", 1)
            name = name.strip()
            if not name:
                continue
            cookies[name] = value.strip()
        return cookies

    def _default_cookie_domain(self) -> str | None:
        config = self.read_config()
        domain = config.get("cookie_domain")
        if isinstance(domain, str) and domain.strip():
            return domain.strip()

        base_url = config.get("base_url")
        if isinstance(base_url, str) and base_url.strip():
            host = urlparse(base_url.strip()).hostname
            if host:
                return host

        return None

    def read_config(self) -> dict[str, Any]:
        try:
            raw = self.config_path.read_text(encoding="utf-8")
        except FileNotFoundError:
            return {}
        data = json.loads(raw) if raw.strip() else {}
        if not isinstance(data, dict):
            raise ValueError(f"Invalid config type in {self.config_path}: expected object")
        return data

    def write_config(self, config: Mapping[str, Any]) -> None:
        _atomic_write_text(self.config_path, json.dumps(dict(config), ensure_ascii=False, indent=2) + "\n")

    def ensure(self) -> None:
        self.dir.mkdir(parents=True, exist_ok=True)

    def load_cookies_into(self, session: requests.Session) -> None:
        self.ensure()
        raw = self._cookies_file_text()
        if raw is None or not raw.strip():
            return

        if self._looks_like_netscape_cookie_file(raw):
            jar = MozillaCookieJar(str(self.cookies_path))
            jar.load(ignore_discard=True, ignore_expires=True)
            session.cookies.update(jar)
            return

        cookie_domain = self._default_cookie_domain()
        for name, value in self._parse_cookie_header(raw).items():
            if cookie_domain:
                session.cookies.set(name, value, domain=cookie_domain, path="/")
            else:
                session.cookies.set(name, value)

    def save_cookies_from(self, session: requests.Session) -> None:
        self.ensure()
        raw = self._cookies_file_text()
        if raw is not None and raw.strip() and not self._looks_like_netscape_cookie_file(raw):
            pairs = [f"{c.name}={c.value}" for c in session.cookies]
            _atomic_write_text(self.cookies_path, "; ".join(pairs) + ("; " if pairs else ""))
            return

        jar = MozillaCookieJar(str(self.cookies_path))
        for cookie in session.cookies:
            jar.set_cookie(cookie)
        jar.save(ignore_discard=True, ignore_expires=True)


class ProfileManager:
    def __init__(self, root: Path | str | None = None) -> None:
        base = root or default_loki_home()
        self.root = Path(base).expanduser().resolve()

    def get(self, platform: str, name: str = "default") -> Profile:
        ref = ProfileRef(platform=platform, name=name)
        profile = Profile(self.root, ref)
        profile.ensure()
        return profile

    def list(self, platform: str | None = None) -> list[ProfileRef]:
        base = self.root / "profiles"
        if not base.exists():
            return []

        refs: list[ProfileRef] = []
        if platform:
            _validate_name("platform", platform)
            platforms = [platform]
        else:
            platforms = [p.name for p in base.iterdir() if p.is_dir()]

        for plat in platforms:
            plat_dir = base / plat
            if not plat_dir.is_dir():
                continue
            for prof_dir in plat_dir.iterdir():
                if prof_dir.is_dir():
                    try:
                        refs.append(ProfileRef(platform=plat, name=prof_dir.name))
                    except ValueError:
                        continue

        refs.sort(key=lambda r: (r.platform, r.name))
        return refs
